第2章:基础图形绑制
2.1 章节概述
在上一章中,我们学习了如何创建和配置 Canvas。现在,终于到了最激动人心的部分——开始绘图!
绘制图形是 Canvas 最核心的能力。无论你是要开发游戏、数据可视化应用,还是图像编辑器,都需要从最基础的图形绘制开始。
本章将循序渐进地讲解:
- 矩形绘制:Canvas 中唯一不需要路径的图形
- 圆与圆弧:理解弧度、方向,绘制圆形和扇形
- 直线与折线:从点到线的绘制方法
- 多边形:三角形、正多边形、星形等复杂形状
- 填充与描边:理解两种绘制模式的区别
学完本章后,你将能够使用 Canvas 绘制各种基础几何图形,为后续学习路径和曲线打下基础。
2.2 Canvas 绘图的基本模式
在深入具体图形之前,让我们先理解 Canvas 绘图的基本模式。Canvas 有两种绘图方式:
2.2.1 立即绘制模式
某些图形可以直接绘制,不需要先构建路径:
// 矩形是唯一可以立即绘制的图形
ctx.fillRect(x, y, width, height); // 填充矩形
ctx.strokeRect(x, y, width, height); // 描边矩形
ctx.clearRect(x, y, width, height); // 清除矩形区域这三个方法非常方便,调用即绘制,不需要其他步骤。
2.2.2 路径绘制模式
除了矩形之外,所有其他图形都需要通过"路径"来绘制。路径绘制的基本流程是:
// 步骤 1:开始一个新路径
ctx.beginPath();
// 步骤 2:构建路径(可以是多个命令的组合)
ctx.moveTo(50, 50); // 移动画笔
ctx.lineTo(150, 50); // 画直线
ctx.lineTo(100, 150); // 画直线
ctx.closePath(); // 可选:闭合路径
// 步骤 3:绘制路径(填充或描边)
ctx.fill(); // 填充
// 或
ctx.stroke(); // 描边理解这个基本流程非常重要。让我们用一个生动的比喻来解释:
想象你在用铅笔画画:
beginPath():拿起一支新铅笔,准备开始画moveTo(x, y):把铅笔移到某个位置,但不留下痕迹(铅笔抬起)lineTo(x, y):把铅笔按在纸上,画一条线到新位置closePath():自动画一条线回到起点,形成闭合图形fill()或stroke():用颜料填充图形,或用墨水描绘轮廓
为什么需要 beginPath()?
每次开始新图形时都应该调用 beginPath()。如果不调用,新的路径会和之前的路径叠加在一起:
// 问题代码:两个圆会"连在一起"
ctx.arc(100, 100, 50, 0, Math.PI * 2); // 第一个圆
ctx.arc(250, 100, 50, 0, Math.PI * 2); // 第二个圆
ctx.stroke(); // 两个圆之间会有一条连线!
// 正确代码:每个圆都是独立的路径
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(250, 100, 50, 0, Math.PI * 2);
ctx.stroke();2.3 矩形绘制
矩形是最简单的图形,Canvas 为它提供了三个专用方法。
2.3.1 fillRect - 填充矩形
fillRect(x, y, width, height) 绘制一个填充的实心矩形。
参数详解:
| 参数 | 含义 | 说明 |
|---|---|---|
| x | 左上角 X 坐标 | 矩形起始点的水平位置 |
| y | 左上角 Y 坐标 | 矩形起始点的垂直位置 |
| width | 宽度 | 正值向右延伸,负值向左延伸 |
| height | 高度 | 正值向下延伸,负值向上延伸 |
基础用法:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 设置填充颜色
ctx.fillStyle = '#4D7CFF';
// 绘制填充矩形
// 从 (50, 50) 开始,宽 200,高 100
ctx.fillRect(50, 50, 200, 100);理解矩形的位置:
Canvas 坐标系:
(0,0) ────────────────────────────────→ X
│
│ (50,50)
│ ┌────────────────────┐
│ │ │
│ │ 200 x 100 │ height=100
│ │ │
│ └────────────────────┘
│ width=200
│
↓
Y使用负值尺寸:
有时候你可能需要从某个点"向左上方"绘制矩形。这时可以使用负值:
// 常规方向:从 (200, 200) 向右下方绘制
ctx.fillStyle = '#4D7CFF';
ctx.fillRect(200, 200, 100, 80);
// 向左绘制:width 为负值
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(200, 200, -100, 80); // 从 x=200 向左延伸到 x=100
// 向上绘制:height 为负值
ctx.fillStyle = '#4ECDC4';
ctx.fillRect(200, 200, 100, -80); // 从 y=200 向上延伸到 y=120
// 向左上方绘制:两者都为负值
ctx.fillStyle = '#FFD93D';
ctx.fillRect(200, 200, -100, -80); // 向左上方绘制图示:
负height (-80)
↑
│
┌───────────┼───────────┐
│ (-100,-80) │ (100,-80) │
│ 黄色 │ 青色 │
│ │ │
负width ───────(200,200)───────→ 正width (+100)
│ │ │
│ (-100,80) │ (100,80) │
│ 红色 │ 蓝色 │
└───────────┼───────────┘
│
↓
正height (+80)2.3.2 strokeRect - 描边矩形
strokeRect(x, y, width, height) 绘制一个只有边框的空心矩形。
// 设置描边颜色和线宽
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
// 绘制描边矩形
ctx.strokeRect(50, 50, 200, 100);理解线宽对边界的影响
这是一个容易被忽视但很重要的概念:描边是以路径为中心,向两边扩展的。
假设你绘制一个从 (50, 50) 开始的矩形,线宽为 10px:
ctx.lineWidth = 10;
ctx.strokeRect(50, 50, 100, 100);实际绘制结果是:
线宽 10px,向两边各扩展 5px:
45 50 150 155
│ │ │ │
45 ─┼───┼────────────────┼───┼─
│███│ │███│ ← 5px
50 ─┼───┼────────────────┼───┼─
│ │ │ │
│ │ │ │
│ │ 内部区域 │ │
│ │ │ │
│ │ │ │
150 ─┼───┼────────────────┼───┼─
│███│ │███│ ← 5px
155 ─┼───┼────────────────┼───┼─
↑ ↑
5px 5px这意味着:
- 矩形实际占用的区域比你指定的要大(每边多出
lineWidth / 2) - 如果你在 (0, 0) 绘制,部分描边会超出 Canvas 边界,被裁掉
- 奇数线宽会导致子像素渲染,边缘模糊
避免描边被裁切:
// 问题:描边被裁切
ctx.lineWidth = 10;
ctx.strokeRect(0, 0, 100, 100); // 左边和上边各有 5px 被裁掉
// 解决:预留空间
const lineWidth = 10;
const margin = lineWidth / 2;
ctx.lineWidth = lineWidth;
ctx.strokeRect(margin, margin, 100, 100); // 完整显示2.3.3 clearRect - 清除矩形区域
clearRect(x, y, width, height) 清除指定矩形区域内的所有像素,使其变为完全透明。
// 先绘制一个蓝色矩形
ctx.fillStyle = '#4D7CFF';
ctx.fillRect(50, 50, 200, 150);
// 然后在中间清除一块区域
ctx.clearRect(100, 80, 100, 90);
// 结果:蓝色矩形中间出现一个透明的"洞"clearRect 的典型用途:
用途1:清空整个 Canvas
// 清空整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height);这是最常见的用法,在动画的每一帧开始时,先清空上一帧的内容。
用途2:动画循环
function animate() {
// 1. 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 更新状态
ball.x += ball.vx;
ball.y += ball.vy;
// 3. 绘制新一帧
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fill();
// 4. 循环
requestAnimationFrame(animate);
}
animate();用途3:实现橡皮擦
canvas.addEventListener('mousemove', (e) => {
if (isErasing) {
const x = e.offsetX;
const y = e.offsetY;
const eraserSize = 20;
// 以鼠标位置为中心清除一个小区域
ctx.clearRect(
x - eraserSize / 2,
y - eraserSize / 2,
eraserSize,
eraserSize
);
}
});注意:clearRect 清除的是像素,不是"图形"。Canvas 不记得你画了什么,只知道像素值。
2.3.4 绘制圆角矩形
Canvas 原生不支持圆角矩形,但这是一个非常常用的图形。我们可以通过路径来实现。
理解圆角矩形的结构:
圆角矩形由 4 条直线 + 4 段圆弧组成:
r ╭──────────────────────╮ r
│ │
│ │
│ │
│ │
r ╰──────────────────────╯ r
每个角都是一个四分之一圆(90度圆弧)
r = 圆角半径实现方法1:使用 arcTo
arcTo 方法可以绘制连接两条切线的圆弧,非常适合绘制圆角:
/**
* 绘制圆角矩形路径
* @param {CanvasRenderingContext2D} ctx - 绑制上下文
* @param {number} x - 左上角 x 坐标
* @param {number} y - 左上角 y 坐标
* @param {number} width - 宽度
* @param {number} height - 高度
* @param {number} radius - 圆角半径
*/
function roundRect(ctx, x, y, width, height, radius) {
// 限制圆角半径不超过矩形的一半
radius = Math.min(radius, width / 2, height / 2);
ctx.beginPath();
// 从左上角圆弧的结束点开始(顺时针绘制)
ctx.moveTo(x + radius, y);
// 上边 → 右上角圆弧
ctx.lineTo(x + width - radius, y);
ctx.arcTo(x + width, y, x + width, y + radius, radius);
// 右边 → 右下角圆弧
ctx.lineTo(x + width, y + height - radius);
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
// 下边 → 左下角圆弧
ctx.lineTo(x + radius, y + height);
ctx.arcTo(x, y + height, x, y + height - radius, radius);
// 左边 → 左上角圆弧
ctx.lineTo(x, y + radius);
ctx.arcTo(x, y, x + radius, y, radius);
ctx.closePath();
}
// 使用示例
roundRect(ctx, 50, 50, 200, 100, 20);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
roundRect(ctx, 50, 200, 200, 100, 20);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke();理解 arcTo 的工作原理:
arcTo(x1, y1, x2, y2, radius) 的工作方式:
当前点 ──────────→ 控制点(x1,y1)
│
│
↓
终点方向(x2,y2)
arcTo 会:
1. 计算从"当前点→控制点"的直线
2. 计算从"控制点→终点方向"的直线
3. 绘制连接这两条线的圆弧(半径为 radius)
4. 自动绘制从"当前点"到"圆弧起点"的直线实现方法2:不同圆角半径
有时候我们需要四个角有不同的圆角半径:
/**
* 绘制可变圆角矩形
* @param {number[]} radius - [左上, 右上, 右下, 左下]
*/
function roundRectVariable(ctx, x, y, width, height, radius) {
// 支持单个值或数组
if (typeof radius === 'number') {
radius = [radius, radius, radius, radius];
}
const [tl, tr, br, bl] = radius;
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + width - tr, y);
ctx.arcTo(x + width, y, x + width, y + tr, tr);
ctx.lineTo(x + width, y + height - br);
ctx.arcTo(x + width, y + height, x + width - br, y + height, br);
ctx.lineTo(x + bl, y + height);
ctx.arcTo(x, y + height, x, y + height - bl, bl);
ctx.lineTo(x, y + tl);
ctx.arcTo(x, y, x + tl, y, tl);
ctx.closePath();
}
// 使用示例:不同的圆角
roundRectVariable(ctx, 50, 50, 200, 100, [0, 20, 0, 20]);
ctx.fillStyle = '#4ECDC4';
ctx.fill();
roundRectVariable(ctx, 50, 200, 200, 100, [30, 0, 30, 0]);
ctx.fillStyle = '#FFD93D';
ctx.fill();现代浏览器的原生支持
从 2021 年开始,现代浏览器开始支持原生的 roundRect 方法:
// Chrome 99+, Firefox 112+, Safari 16+
ctx.beginPath();
ctx.roundRect(50, 50, 200, 100, 20); // 统一圆角
ctx.fill();
ctx.beginPath();
ctx.roundRect(50, 200, 200, 100, [10, 20, 30, 40]); // 不同圆角
ctx.stroke();在使用前最好检测支持:
if (typeof ctx.roundRect === 'function') {
// 使用原生方法
ctx.roundRect(x, y, width, height, radius);
} else {
// 使用自定义实现
roundRect(ctx, x, y, width, height, radius);
}2.4 圆与圆弧
圆和圆弧是通过 arc 方法绘制的。这是 Canvas 中最重要的方法之一,我们需要深入理解它。
2.4.1 arc 方法详解
方法签名:
ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise)参数详解:
| 参数 | 含义 | 说明 |
|---|---|---|
| x | 圆心 X 坐标 | 圆的中心点水平位置 |
| y | 圆心 Y 坐标 | 圆的中心点垂直位置 |
| radius | 半径 | 圆的半径,必须为正数 |
| startAngle | 起始角度 | 弧度,不是角度! |
| endAngle | 结束角度 | 弧度,不是角度! |
| counterclockwise | 是否逆时针 | 可选,默认 false(顺时针) |
2.4.2 理解弧度
这是学习 arc 方法时最容易困惑的地方。Canvas 使用弧度(Radian)而不是角度(Degree)。
弧度与角度的关系:
一个完整的圆 = 360° = 2π 弧度
所以:
1° = π/180 弧度
1 弧度 = 180/π 度 ≈ 57.3°常用角度对应的弧度:
| 角度 | 弧度 | JavaScript 表示 |
|---|---|---|
| 0° | 0 | 0 |
| 30° | π/6 | Math.PI / 6 |
| 45° | π/4 | Math.PI / 4 |
| 60° | π/3 | Math.PI / 3 |
| 90° | π/2 | Math.PI / 2 |
| 180° | π | Math.PI |
| 270° | 3π/2 | Math.PI * 1.5 |
| 360° | 2π | Math.PI * 2 |
角度与弧度的转换函数:
/**
* 角度转弧度
* @param {number} degrees - 角度值
* @returns {number} 弧度值
*/
function degToRad(degrees) {
return degrees * (Math.PI / 180);
}
/**
* 弧度转角度
* @param {number} radians - 弧度值
* @returns {number} 角度值
*/
function radToDeg(radians) {
return radians * (180 / Math.PI);
}
// 使用示例
console.log(degToRad(90)); // 1.5707963... (即 π/2)
console.log(radToDeg(Math.PI)); // 1802.4.3 理解 Canvas 的角度方向
Canvas 的角度系统与数学课本上的不太一样:
Canvas 角度方向:
270° (-π/2 或 3π/2)
│
│
180° (π) ───────●─────── 0° (0 或 2π) ← 起始点(3点钟方向)
│
│
90° (π/2)
特点:
1. 0° 在右侧(3点钟方向),不是上方
2. 角度顺时针增加(与数学坐标系相反)
3. 默认绘制方向是顺时针为什么是这样?
这是因为 Canvas 的 Y 轴向下。在标准数学坐标系中,Y 轴向上,所以逆时针是正方向。但 Canvas 的 Y 轴向下,导致角度的方向"翻转"了。
2.4.4 绘制完整的圆
绘制一个完整的圆非常简单:
// 绘制一个圆心在 (150, 150),半径 50 的圆
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI * 2); // 从 0 到 2π,完整的圆
ctx.fillStyle = '#4D7CFF';
ctx.fill();让我们分解这个过程:
ctx.beginPath(); // 开始新路径
ctx.arc(
150, // 圆心 x
150, // 圆心 y
50, // 半径
0, // 起始角度:0 弧度(3点钟方向)
Math.PI * 2 // 结束角度:2π 弧度(转一圈回到起点)
);
ctx.fill(); // 填充圆形描边圆形:
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI * 2);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke(); // 描边而不是填充2.4.5 绘制圆弧
圆弧就是圆的一部分。通过调整 startAngle 和 endAngle 来控制绘制哪一部分:
绘制上半圆:
// 从 π 到 0(或 2π),即从 9 点钟到 3 点钟,经过 12 点钟
ctx.beginPath();
ctx.arc(150, 100, 50, Math.PI, 0); // 或 Math.PI * 2
ctx.stroke();
// 图示:
// ╭───────╮
// │ │
// ─────┴───────┴─────绘制下半圆:
// 从 0 到 π,即从 3 点钟到 9 点钟,经过 6 点钟
ctx.beginPath();
ctx.arc(150, 100, 50, 0, Math.PI);
ctx.stroke();
// 图示:
// ─────┬───────┬─────
// │ │
// ╰───────╯绘制四分之一圆:
// 从 0 到 π/2,即从 3 点钟到 6 点钟
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI / 2);
ctx.stroke();
// 图示:
// ────●
// │╲
// │ ╲
// │ ╲
// ╰───2.4.6 顺时针与逆时针
arc 方法的最后一个参数 counterclockwise 控制绘制方向:
// 顺时针(默认):从 0 到 π/2
ctx.beginPath();
ctx.arc(100, 150, 50, 0, Math.PI / 2, false);
ctx.stroke();
// 结果:绘制右下角的四分之一圆
// 逆时针:从 0 到 π/2
ctx.beginPath();
ctx.arc(250, 150, 50, 0, Math.PI / 2, true);
ctx.stroke();
// 结果:绘制剩下的四分之三圆!图示对比:
顺时针(false)从 0 到 π/2: 逆时针(true)从 0 到 π/2:
│ ╭───╮
│ │ │
────────● ────────● │
│╲ │
│ ╲ ╰────────╯
│ ╲
╰───╮ 绘制了"其他部分"理解绘制方向的关键:
给定起始角和结束角,Canvas 会沿着指定方向绘制。方向不同,绘制的圆弧部分就不同:
- 顺时针(counterclockwise = false):从 startAngle 向角度增大的方向绘制到 endAngle
- 逆时针(counterclockwise = true):从 startAngle 向角度减小的方向绘制到 endAngle
2.4.7 绘制扇形
扇形是"一块披萨"的形状,由两条半径和一段圆弧组成。
/**
* 绘制扇形
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx - 圆心 x
* @param {number} cy - 圆心 y
* @param {number} radius - 半径
* @param {number} startAngle - 起始角度(弧度)
* @param {number} endAngle - 结束角度(弧度)
*/
function drawSector(ctx, cx, cy, radius, startAngle, endAngle) {
ctx.beginPath();
// 1. 移动到圆心
ctx.moveTo(cx, cy);
// 2. 画线到圆弧起点(arc 会自动处理)
// 3. 绘制圆弧
ctx.arc(cx, cy, radius, startAngle, endAngle);
// 4. closePath 会自动画线回到圆心
ctx.closePath();
}
// 绘制一个四分之一扇形
drawSector(ctx, 200, 200, 100, 0, Math.PI / 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#2952CC';
ctx.lineWidth = 2;
ctx.stroke();绘制过程图解:
步骤 1: moveTo(cx, cy)
移动到圆心
步骤 2: arc(cx, cy, radius, 0, π/2)
Canvas 自动从圆心画线到圆弧起点
然后绘制圆弧
╭──────
╱
────────● 圆心
步骤 3: closePath()
自动从圆弧终点画线回到圆心
╭──────
╱ │
────────●────────╯应用:绘制饼图
function drawPieChart(ctx, cx, cy, radius, data) {
// data 格式: [{ value: 30, color: '#FF6B6B' }, ...]
const total = data.reduce((sum, item) => sum + item.value, 0);
let currentAngle = -Math.PI / 2; // 从 12 点钟方向开始
data.forEach((item) => {
// 计算这个扇形占的角度
const sliceAngle = (item.value / total) * Math.PI * 2;
// 绘制扇形
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, currentAngle, currentAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = item.color;
ctx.fill();
// 可选:添加描边
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 更新角度
currentAngle += sliceAngle;
});
}
// 使用示例
const pieData = [
{ value: 35, color: '#FF6B6B' },
{ value: 25, color: '#4ECDC4' },
{ value: 20, color: '#45B7D1' },
{ value: 20, color: '#96CEB4' },
];
drawPieChart(ctx, 200, 200, 150, pieData);2.4.8 绘制圆环
圆环是"甜甜圈"的形状,有内外两个半径。
/**
* 绘制圆环
* @param {number} outerRadius - 外半径
* @param {number} innerRadius - 内半径
*/
function drawRing(ctx, cx, cy, outerRadius, innerRadius, startAngle, endAngle) {
ctx.beginPath();
// 1. 绘制外圆弧(顺时针)
ctx.arc(cx, cy, outerRadius, startAngle, endAngle, false);
// 2. 绘制内圆弧(逆时针,形成闭合区域)
ctx.arc(cx, cy, innerRadius, endAngle, startAngle, true);
ctx.closePath();
}
// 完整的圆环
drawRing(ctx, 200, 200, 100, 60, 0, Math.PI * 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
// 部分圆环(进度条效果)
drawRing(ctx, 200, 200, 100, 60, -Math.PI / 2, Math.PI / 2);
ctx.fillStyle = '#4ECDC4';
ctx.fill();原理解释:
为什么内圆弧要逆时针绘制?这涉及到 Canvas 的填充规则。简单来说:
- 外圆弧顺时针绘制,定义了外边界
- 内圆弧逆时针绘制,定义了内边界("挖空"的部分)
- 两者组合形成一个环形区域
外圆弧(顺时针):定义外边界
╭───────────╮
╱ ╲
╱ ╲
╲ ╱
╲ ╱
╰───────────╯
内圆弧(逆时针):定义内边界
╭───────────╮
╱ ╭───────╮ ╲
╱ ╲ ╱ ╲
╲ ╱ ╲ ╱
╲ ╰───────╯ ╱
╰───────────╯
填充结果:环形区域
██████████████
██ ██
██ ██
██ ██
██ ██
██████████████2.4.9 arcTo 方法
除了 arc,还有一个 arcTo 方法,用于绘制连接两条切线的圆弧。
ctx.arcTo(x1, y1, x2, y2, radius);工作原理:
arcTo 需要三个点:
1. 当前点(画笔位置)
2. 控制点 (x1, y1)
3. 终点方向 (x2, y2)
Canvas 会:
1. 从当前点画一条直线,与控制点相切
2. 从控制点画一条直线,指向 (x2, y2)
3. 在两条直线之间绘制指定半径的圆弧
当前点 ─────────────→ 控制点 (x1, y1)
│
│
↓
终点方向 (x2, y2)实际使用示例:
ctx.beginPath();
ctx.moveTo(50, 100); // 起点
ctx.arcTo(
150, 100, // 控制点
150, 200, // 终点方向
30 // 圆弧半径
);
ctx.lineTo(150, 200); // 连接到终点
ctx.stroke();
// 结果:一个带圆角的直角转弯
//
// (50,100) ─────────────┐
// │ ← 30px 半径的圆角
// │
// ↓
// (150,200)arcTo 最常用于绘制圆角折线或圆角矩形(我们在上一节已经展示过)。
2.5 直线与折线
2.5.1 moveTo 和 lineTo
这两个方法是绘制直线的基础:
moveTo(x, y):移动画笔到指定位置,不绘制任何内容lineTo(x, y):从当前位置绘制直线到指定位置
// 绘制一条直线
ctx.beginPath();
ctx.moveTo(50, 50); // 移动到起点
ctx.lineTo(200, 100); // 画线到终点
ctx.stroke(); // 执行描边理解 moveTo 的作用:
moveTo 就像是把笔抬起来,移动到新位置,然后放下。它不会留下任何痕迹,但会更新"当前点"的位置。
// 不使用 moveTo:从上次的终点继续
ctx.beginPath();
ctx.lineTo(100, 50); // 从 (0, 0) 画到 (100, 50)
ctx.lineTo(200, 50); // 继续画到 (200, 50)
ctx.stroke();
// 结果:一条从左上角开始的折线
// 使用 moveTo:指定起点
ctx.beginPath();
ctx.moveTo(50, 100); // 先移动到 (50, 100)
ctx.lineTo(150, 100); // 从 (50, 100) 画到 (150, 100)
ctx.stroke();
// 结果:一条水平线2.5.2 绘制折线
折线就是多个连续的线段:
// 方法 1:手动连续绘制
ctx.beginPath();
ctx.moveTo(50, 150);
ctx.lineTo(100, 50);
ctx.lineTo(150, 150);
ctx.lineTo(200, 50);
ctx.lineTo(250, 150);
ctx.stroke();
// 方法 2:使用数组和循环
function drawPolyline(ctx, points) {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
}
// 使用
const zigzag = [
{ x: 50, y: 150 },
{ x: 100, y: 50 },
{ x: 150, y: 150 },
{ x: 200, y: 50 },
{ x: 250, y: 150 },
];
drawPolyline(ctx, zigzag);2.5.3 绘制正弦波
一个更复杂的折线示例:
function drawSineWave(ctx, startX, startY, width, amplitude, frequency) {
ctx.beginPath();
ctx.moveTo(startX, startY);
// 每隔 2px 计算一个点
for (let x = 0; x <= width; x += 2) {
const y = startY + Math.sin(x * frequency) * amplitude;
ctx.lineTo(startX + x, y);
}
ctx.stroke();
}
// 绘制正弦波
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 2;
drawSineWave(ctx, 50, 200, 500, 50, 0.02);2.5.4 绘制网格
网格是由多条平行直线组成的:
/**
* 绘制网格
* @param {number} gridSize - 网格间距
*/
function drawGrid(ctx, width, height, gridSize, color = '#e0e0e0') {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 0.5;
ctx.beginPath();
// 绘制垂直线
for (let x = 0; x <= width; x += gridSize) {
ctx.moveTo(x + 0.5, 0); // +0.5 使 1px 线条清晰
ctx.lineTo(x + 0.5, height);
}
// 绘制水平线
for (let y = 0; y <= height; y += gridSize) {
ctx.moveTo(0, y + 0.5);
ctx.lineTo(width, y + 0.5);
}
ctx.stroke();
ctx.restore();
}
// 使用
drawGrid(ctx, canvas.width, canvas.height, 20);注意 0.5px 偏移:还记得第一章讲的 1px 线条清晰度问题吗?这里我们使用了 + 0.5 来确保网格线清晰。
2.5.5 1px 线条清晰度问题详解
这是一个非常重要的问题,值得再次深入讨论。
问题根源:
Canvas 的坐标指的是像素的边界,而不是像素的中心。当你在整数坐标上绘制 1px 线条时,线条会跨越两行/列像素:
整数坐标 y=50 画 1px 线:
│ y=49 │ y=50 │ y=51 │
│ │ │ │
├──────────┼──────────┼──────────┤
│ │░░░░░░░░░░│ │
│ │ 线条中心 │ │
│ │░░░░░░░░░░│ │
├──────────┼──────────┼──────────┤
线条中心在 y=50 像素边界上
向上扩展 0.5px 进入 y=49 像素
向下扩展 0.5px 进入 y=50 像素
两个像素各填充 50%,颜色变淡解决方案:
偏移 0.5px,y=50.5 画 1px 线:
│ y=49 │ y=50 │ y=51 │
│ │ │ │
├──────────┼──────────┼──────────┤
│ │██████████│ │
│ │ 线条中心 │ │
│ │██████████│ │
├──────────┼──────────┼──────────┤
线条中心在 y=50 像素中心
完整填充 y=50 这一行像素
颜色 100%,线条清晰封装函数:
/**
* 绘制清晰的直线
*/
function crispLine(ctx, x1, y1, x2, y2) {
// 奇数线宽需要偏移 0.5
const offset = ctx.lineWidth % 2 === 1 ? 0.5 : 0;
ctx.beginPath();
ctx.moveTo(
Math.round(x1) + offset,
Math.round(y1) + offset
);
ctx.lineTo(
Math.round(x2) + offset,
Math.round(y2) + offset
);
ctx.stroke();
}
// 清晰的 1px 线
ctx.lineWidth = 1;
crispLine(ctx, 50, 50, 200, 50);
// 清晰的 3px 线
ctx.lineWidth = 3;
crispLine(ctx, 50, 100, 200, 100);
// 2px 线不需要偏移
ctx.lineWidth = 2;
crispLine(ctx, 50, 150, 200, 150); // 自动不偏移2.6 多边形绘制
Canvas 没有直接绘制多边形的方法,我们需要使用路径来构建。
2.6.1 三角形
三角形是最简单的多边形,由三个顶点组成:
/**
* 绘制三角形
*/
function drawTriangle(ctx, x1, y1, x2, y2, x3, y3) {
ctx.beginPath();
ctx.moveTo(x1, y1); // 第一个顶点
ctx.lineTo(x2, y2); // 第二个顶点
ctx.lineTo(x3, y3); // 第三个顶点
ctx.closePath(); // 自动连接回第一个顶点
}
// 普通三角形
drawTriangle(ctx, 100, 50, 50, 150, 150, 150);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
// 描边三角形
drawTriangle(ctx, 250, 50, 200, 150, 300, 150);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke();closePath() 的作用:
closePath() 会自动绘制一条从当前点回到路径起点的直线。对于多边形来说,这确保了图形是闭合的:
// 不使用 closePath:图形不闭合
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(50, 150);
ctx.lineTo(150, 150);
// 没有 closePath
ctx.stroke();
// 结果:一个开口的 V 形
// 使用 closePath:图形闭合
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(50, 150);
ctx.lineTo(150, 150);
ctx.closePath(); // 自动连接回 (100, 50)
ctx.stroke();
// 结果:完整的三角形2.6.2 等边三角形
绘制等边三角形需要一点数学计算:
/**
* 绘制等边三角形
* @param {number} cx, cy - 中心点坐标
* @param {number} size - 边长
*/
function drawEquilateralTriangle(ctx, cx, cy, size) {
// 等边三角形的高度 = 边长 × √3 / 2
const height = size * Math.sqrt(3) / 2;
// 计算三个顶点
// 顶点在上方,距离中心 2/3 的高度
const topY = cy - height * 2 / 3;
// 底边两个顶点在下方,距离中心 1/3 的高度
const bottomY = cy + height / 3;
const leftX = cx - size / 2;
const rightX = cx + size / 2;
ctx.beginPath();
ctx.moveTo(cx, topY); // 顶点
ctx.lineTo(rightX, bottomY); // 右下
ctx.lineTo(leftX, bottomY); // 左下
ctx.closePath();
}
// 使用
drawEquilateralTriangle(ctx, 150, 120, 100);
ctx.fillStyle = '#4ECDC4';
ctx.fill();几何原理图解:
等边三角形的几何关系:
顶点
●
/│\
/ │ \ 高度 h = 边长 s × √3/2
/ │ \
/ │ \ 中心到顶点 = h × 2/3
/ │ \ 中心到底边 = h × 1/3
/ ● \
/ 中心 \
/ \
●─────────────────●
左下 右下
边长 s2.6.3 正多边形
正多边形是所有边长相等、所有角度相等的多边形。我们可以用通用公式来绘制任意正多边形:
/**
* 绘制正多边形
* @param {number} cx, cy - 中心点坐标
* @param {number} radius - 外接圆半径
* @param {number} sides - 边数(至少 3)
* @param {number} rotation - 旋转角度(弧度)
*/
function drawRegularPolygon(ctx, cx, cy, radius, sides, rotation = -Math.PI / 2) {
if (sides < 3) {
console.error('正多边形至少需要 3 条边');
return;
}
ctx.beginPath();
for (let i = 0; i < sides; i++) {
// 计算每个顶点的角度
// 默认 rotation = -π/2 使第一个顶点在正上方
const angle = rotation + (i * 2 * Math.PI / sides);
// 计算顶点坐标
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
}
// 绘制各种正多边形
const polygons = [
{ sides: 3, name: '三角形' },
{ sides: 4, name: '正方形' },
{ sides: 5, name: '五边形' },
{ sides: 6, name: '六边形' },
{ sides: 8, name: '八边形' },
{ sides: 12, name: '十二边形' },
];
polygons.forEach((polygon, index) => {
const cx = 100 + (index % 3) * 150;
const cy = 100 + Math.floor(index / 3) * 150;
drawRegularPolygon(ctx, cx, cy, 50, polygon.sides);
ctx.fillStyle = `hsl(${index * 50}, 70%, 60%)`;
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
});理解正多边形的数学原理:
正多边形的顶点均匀分布在一个圆上(外接圆)
以正五边形为例:
每个顶点之间的角度 = 360° / 5 = 72°
顶点0 (0°)
●
/│\
/ │ \
顶点4 / │ \ 顶点1
●────/───●───\────●
\ 中心 /
\ /
\ /
\ /
\ /
●●
顶点3 顶点2
每个顶点的坐标:
x = cx + radius × cos(角度)
y = cy + radius × sin(角度)2.6.4 星形
星形是一种更复杂的形状,由外顶点和内顶点交替组成:
/**
* 绘制星形
* @param {number} cx, cy - 中心点
* @param {number} outerRadius - 外半径(角尖到中心的距离)
* @param {number} innerRadius - 内半径(凹点到中心的距离)
* @param {number} points - 角数
* @param {number} rotation - 旋转角度
*/
function drawStar(ctx, cx, cy, outerRadius, innerRadius, points, rotation = -Math.PI / 2) {
ctx.beginPath();
// 星形有 points * 2 个顶点(外顶点和内顶点交替)
const totalVertices = points * 2;
for (let i = 0; i < totalVertices; i++) {
// 奇数索引是内顶点,偶数索引是外顶点
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const angle = rotation + (i * Math.PI / points);
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
}
// 五角星
drawStar(ctx, 100, 100, 80, 35, 5);
ctx.fillStyle = '#FFD700'; // 金色
ctx.fill();
ctx.strokeStyle = '#B8860B';
ctx.lineWidth = 2;
ctx.stroke();
// 六角星(大卫之星)
drawStar(ctx, 250, 100, 80, 45, 6);
ctx.fillStyle = '#4ECDC4';
ctx.fill();
// 八角星
drawStar(ctx, 400, 100, 80, 50, 8);
ctx.fillStyle = '#FF6B6B';
ctx.fill();星形的几何原理:
五角星的顶点分布:
外顶点(outerRadius):●
内顶点(innerRadius):○
● (0°)
/│\
/ │ \
/ │ \
○───/───┼───\───○ (36°, 324°)
/ │ \
/ │ \
/ │ \
●────────┼────────● (72°, 288°)
│
│
○ (180°)
角度计算:
- 外顶点:0°, 72°, 144°, 216°, 288°(每 72° 一个)
- 内顶点:36°, 108°, 180°, 252°, 324°(在外顶点之间)2.6.5 任意多边形
有时候我们需要绘制不规则的多边形,可以直接指定顶点坐标:
/**
* 绘制任意多边形
* @param {Array<{x: number, y: number}>} points - 顶点数组
* @param {boolean} closed - 是否闭合
*/
function drawPolygon(ctx, points, closed = true) {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
if (closed) {
ctx.closePath();
}
}
// 绘制一个箭头形状
const arrowPoints = [
{ x: 50, y: 100 },
{ x: 150, y: 100 },
{ x: 150, y: 70 },
{ x: 200, y: 120 },
{ x: 150, y: 170 },
{ x: 150, y: 140 },
{ x: 50, y: 140 },
];
drawPolygon(ctx, arrowPoints);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#2952CC';
ctx.lineWidth = 2;
ctx.stroke();2.7 填充与描边
到目前为止,我们一直在使用 fill() 和 stroke() 方法。让我们深入理解它们。
2.7.1 fill() - 填充
fill() 方法填充当前路径包围的区域:
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();fillStyle 支持的值类型:
// 1. 颜色字符串
ctx.fillStyle = 'red';
ctx.fillStyle = '#FF6B6B';
ctx.fillStyle = 'rgb(255, 107, 107)';
ctx.fillStyle = 'rgba(255, 107, 107, 0.5)';
ctx.fillStyle = 'hsl(0, 100%, 71%)';
// 2. 渐变(后续章节详细讲解)
const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, 'red');
gradient.addColorStop(1, 'blue');
ctx.fillStyle = gradient;
// 3. 图案(后续章节详细讲解)
const pattern = ctx.createPattern(image, 'repeat');
ctx.fillStyle = pattern;2.7.2 stroke() - 描边
stroke() 方法描绘当前路径的轮廓:
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 5;
ctx.stroke();相关属性:
| 属性 | 说明 | 默认值 |
|---|---|---|
| strokeStyle | 描边颜色 | '#000000' |
| lineWidth | 线条宽度 | 1 |
| lineCap | 线端样式 | 'butt' |
| lineJoin | 连接样式 | 'miter' |
| miterLimit | 斜接限制 | 10 |
2.7.3 lineCap - 线端样式
lineCap 控制线条端点的形状:
ctx.lineWidth = 20;
// butt:平直端点(默认)
ctx.lineCap = 'butt';
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(200, 50);
ctx.stroke();
// round:圆形端点
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(200, 100);
ctx.stroke();
// square:方形端点(比 butt 长半个线宽)
ctx.lineCap = 'square';
ctx.beginPath();
ctx.moveTo(50, 150);
ctx.lineTo(200, 150);
ctx.stroke();图示对比:
lineWidth = 20
butt(默认):
端点与路径端点齐平
══════════════════
50 200
round:
端点是半圆
●════════════════●
40 210
square:
端点是半个线宽的方形
■════════════════■
40 2102.7.4 lineJoin - 连接样式
lineJoin 控制两条线段连接处的形状:
ctx.lineWidth = 20;
// miter:尖角连接(默认)
ctx.lineJoin = 'miter';
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(100, 100);
ctx.lineTo(150, 50);
ctx.stroke();
// round:圆角连接
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(200, 50);
ctx.lineTo(250, 100);
ctx.lineTo(300, 50);
ctx.stroke();
// bevel:斜角连接
ctx.lineJoin = 'bevel';
ctx.beginPath();
ctx.moveTo(350, 50);
ctx.lineTo(400, 100);
ctx.lineTo(450, 50);
ctx.stroke();图示对比:
lineWidth = 20
miter(尖角): round(圆角): bevel(斜角):
╱╲ ╱╲ ╱╲
╱ ╲ ╱ ╲ ╱ ╲
╱ ╲ ╱ ╲ ╱────╲
╱ ╲ ╱ ╲ ╱ ╲2.7.5 填充与描边的顺序
当你需要同时填充和描边时,顺序很重要:
// 方案1:先填充后描边(推荐)
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 10;
ctx.stroke();
// 结果:描边完整可见,覆盖在填充上
// 方案2:先描边后填充
ctx.beginPath();
ctx.rect(200, 50, 100, 100);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 10;
ctx.stroke();
ctx.fillStyle = '#4D7CFF';
ctx.fill();
// 结果:描边的内半部分被填充覆盖为什么会这样?
因为描边是以路径为中心向两侧扩展的。描边有一半在路径内部,一半在路径外部。
如果先描边后填充,填充会覆盖描边的内部部分:
先填充后描边: 先描边后填充:
┌──────────────┐ ┌──────────────┐
│██ 描边外半 ██│ │██ 描边外半 ██│
│██┌────────┐██│ │ ┌────────┐ │
│██│ 填 充 │██│ │ │填充覆盖│ │
│██│ │██│ │ │描边内半│ │
│██└────────┘██│ │ └────────┘ │
│██ 描边外半 ██│ │██ 描边外半 ██│
└──────────────┘ └──────────────┘
描边完整显示 只显示外半边框2.7.6 填充规则
fill() 方法接受一个可选参数来指定填充规则:
ctx.fill(); // 默认:'nonzero'
ctx.fill('nonzero'); // 非零环绕规则
ctx.fill('evenodd'); // 奇偶规则这两个规则有什么区别?
当路径相互交叉或包含时,填充规则决定哪些区域被填充。
非零环绕规则(nonzero):
想象从某个点向外发射一条射线。计算路径穿过射线的次数,顺时针穿过 +1,逆时针穿过 -1。如果总数不为 0,该点在填充区域内。
奇偶规则(evenodd):
同样发射一条射线。简单统计路径穿过射线的次数。如果是奇数,该点在填充区域内;偶数则不在。
实际演示:
// 绘制两个同心圆(同向)
ctx.beginPath();
ctx.arc(100, 150, 80, 0, Math.PI * 2, false); // 外圆,顺时针
ctx.arc(100, 150, 40, 0, Math.PI * 2, false); // 内圆,顺时针
ctx.fillStyle = '#4D7CFF';
ctx.fill('nonzero'); // 内圆也被填充
// 使用 evenodd 规则
ctx.beginPath();
ctx.arc(280, 150, 80, 0, Math.PI * 2); // 外圆
ctx.arc(280, 150, 40, 0, Math.PI * 2); // 内圆
ctx.fillStyle = '#FF6B6B';
ctx.fill('evenodd'); // 内圆变成空洞图示:
nonzero 规则(同向路径): evenodd 规则:
╭─────────────────╮ ╭─────────────────╮
╱ ╭─────────────╮ ╲ ╱ ╭─────────────╮ ╲
╱ ╱ ╲ ╲ ╱ ╱ ╲ ╲
│ │ █ 内圆也填充 █ │ │ │ │ 空 洞 │ │
│ │ ███████████████ │ │ │ │ │ │
╲ ╲ ╱ ╱ ╲ ╲ ╱ ╱
╲ ╰─────────────╯ ╱ ╲ ╰─────────────╯ ╱
╰─────────────────╯ ╰─────────────────╯利用填充规则创建空心图形:
// 方法1:使用 evenodd
ctx.beginPath();
ctx.rect(50, 50, 200, 150); // 外矩形
ctx.rect(80, 80, 140, 90); // 内矩形
ctx.fill('evenodd');
// 方法2:使用反向路径
ctx.beginPath();
// 外矩形:顺时针
ctx.moveTo(300, 50);
ctx.lineTo(500, 50);
ctx.lineTo(500, 200);
ctx.lineTo(300, 200);
ctx.closePath();
// 内矩形:逆时针
ctx.moveTo(330, 80);
ctx.lineTo(330, 170);
ctx.lineTo(470, 170);
ctx.lineTo(470, 80);
ctx.closePath();
ctx.fill('nonzero');2.8 综合实战
让我们用本章学到的知识创建一些实用的图形。
2.8.1 绘制一个按钮
function drawButton(ctx, x, y, width, height, text, options = {}) {
const {
backgroundColor = '#4D7CFF',
textColor = '#FFFFFF',
borderRadius = 8,
fontSize = 16,
} = options;
// 绘制圆角矩形背景
ctx.beginPath();
roundRect(ctx, x, y, width, height, borderRadius);
ctx.fillStyle = backgroundColor;
ctx.fill();
// 绘制文字
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + width / 2, y + height / 2);
}
// 使用
drawButton(ctx, 50, 50, 120, 40, '点击我');
drawButton(ctx, 200, 50, 120, 40, '取消', {
backgroundColor: '#FF6B6B'
});2.8.2 绘制进度条
function drawProgressBar(ctx, x, y, width, height, progress, options = {}) {
const {
backgroundColor = '#E0E0E0',
fillColor = '#4ECDC4',
borderRadius = 4,
} = options;
// 背景
ctx.beginPath();
roundRect(ctx, x, y, width, height, borderRadius);
ctx.fillStyle = backgroundColor;
ctx.fill();
// 进度
const fillWidth = width * Math.max(0, Math.min(1, progress));
if (fillWidth > 0) {
ctx.beginPath();
roundRect(ctx, x, y, fillWidth, height, borderRadius);
ctx.fillStyle = fillColor;
ctx.fill();
}
// 文字
ctx.fillStyle = '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${Math.round(progress * 100)}%`, x + width / 2, y + height / 2);
}
// 使用
drawProgressBar(ctx, 50, 100, 300, 24, 0.75);2.8.3 绘制简易图表
function drawBarChart(ctx, x, y, width, height, data) {
const maxValue = Math.max(...data.map(d => d.value));
const barCount = data.length;
const barWidth = (width * 0.6) / barCount;
const gap = (width * 0.4) / (barCount + 1);
// 绘制坐标轴
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + height);
ctx.lineTo(x + width, y + height);
ctx.stroke();
// 绘制柱子
data.forEach((item, index) => {
const barHeight = (item.value / maxValue) * height * 0.9;
const barX = x + gap + index * (barWidth + gap);
const barY = y + height - barHeight;
// 柱子
ctx.fillStyle = item.color || '#4D7CFF';
ctx.fillRect(barX, barY, barWidth, barHeight);
// 标签
ctx.fillStyle = '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(item.label, barX + barWidth / 2, y + height + 15);
// 数值
ctx.fillText(item.value.toString(), barX + barWidth / 2, barY - 5);
});
}
// 使用
const chartData = [
{ label: 'Jan', value: 65, color: '#FF6B6B' },
{ label: 'Feb', value: 85, color: '#4ECDC4' },
{ label: 'Mar', value: 45, color: '#45B7D1' },
{ label: 'Apr', value: 95, color: '#96CEB4' },
{ label: 'May', value: 70, color: '#FFD93D' },
];
drawBarChart(ctx, 50, 150, 400, 200, chartData);2.8.4 绘制仪表盘
function drawGauge(ctx, cx, cy, radius, value, options = {}) {
const {
minValue = 0,
maxValue = 100,
startAngle = Math.PI * 0.75,
endAngle = Math.PI * 2.25,
backgroundColor = '#E0E0E0',
fillColor = '#4D7CFF',
lineWidth = 20,
} = options;
// 计算角度
const progress = (value - minValue) / (maxValue - minValue);
const currentAngle = startAngle + (endAngle - startAngle) * progress;
// 背景弧
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, endAngle);
ctx.strokeStyle = backgroundColor;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.stroke();
// 进度弧
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, currentAngle);
ctx.strokeStyle = fillColor;
ctx.stroke();
// 中心数值
ctx.fillStyle = '#333';
ctx.font = 'bold 32px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(value).toString(), cx, cy);
// 单位
ctx.font = '14px sans-serif';
ctx.fillText('%', cx, cy + 25);
}
// 使用
drawGauge(ctx, 200, 250, 80, 72);2.9 本章小结
恭喜你完成了 Canvas 基础图形绘制的学习!让我们回顾关键知识点:
核心知识
| 图形类型 | 方法/实现 | 要点 |
|---|---|---|
| 矩形 | fillRect, strokeRect, clearRect | 唯一不需要路径的图形 |
| 圆角矩形 | arcTo + 路径 | 需要手动实现或使用原生 roundRect |
| 圆/圆弧 | arc(x, y, r, start, end, ccw) | 角度使用弧度,方向默认顺时针 |
| 扇形 | moveTo 圆心 + arc | 用于饼图 |
| 圆环 | 外弧顺时针 + 内弧逆时针 | 利用路径方向创建空心 |
| 直线 | moveTo + lineTo | 注意 0.5px 偏移问题 |
| 多边形 | 循环 lineTo + closePath | 需要计算顶点坐标 |
| 星形 | 交替内外半径 | 顶点数 = 角数 × 2 |
绘制模式
| 概念 | 说明 |
|---|---|
| fill() | 填充闭合区域 |
| stroke() | 描绘路径轮廓 |
| 顺序 | 先填充后描边,描边完整可见 |
| 填充规则 | nonzero(默认)和 evenodd |
常见问题
| 问题 | 解决方案 |
|---|---|
| 1px 线条模糊 | 坐标偏移 0.5px |
| 图形连在一起 | 每个图形前调用 beginPath() |
| 描边被裁切 | 预留 lineWidth/2 的边距 |
| 多边形不闭合 | 使用 closePath() |
2.10 练习题
基础练习
绘制一个棋盘(8×8 的黑白格子)
绘制一个笑脸(圆形脸 + 两个眼睛 + 嘴巴弧线)
绘制一组圆角按钮,鼠标悬停时改变颜色
进阶练习
实现一个可配置的饼图组件:
- 支持任意数量的数据项
- 支持自定义颜色
- 显示百分比标签
实现一个动态进度环:
- 圆环形式显示进度
- 支持动画效果
- 中心显示百分比
挑战练习
- 实现一个简易绘图工具:
- 支持绘制矩形、圆形、直线
- 支持填充和描边模式
- 支持选择颜色和线宽
下一章预告:在第3章中,我们将深入学习 Canvas 的路径系统和贝塞尔曲线。你将学会绘制平滑的曲线、复杂的自定义形状,以及理解路径的工作原理。
文档版本:v2.0
字数统计:约 16,000 字
代码示例:50+ 个
