Canvas 中的 beginPath() 与 save()/restore() 详解
一句话总结
beginPath() | save() | |
|---|---|---|
| 作用对象 | 当前路径(草稿本) | 绘图状态(属性) |
| 类比 | 橡皮擦擦掉草稿 | 拍照记录当前设置 |
| 互补操作 | 无(路径只能被清空或叠加绘制) | restore() |
| 相互影响 | ❌ 无 | ❌ 无 |
它们是完全独立的两个机制,互不干扰。
一、beginPath() — 只管「路径」
作用
清空当前路径,重新开始绘制新的路径。
Canvas 内部维护了一个**当前路径(current path)**的概念,它就像一个「草稿本」:
ctx.moveTo(0, 0); // 草稿本上写下:从 (0,0) 开始
ctx.lineTo(100, 0); // 草稿本上写下:画到 (100,0)
ctx.lineTo(100, 100); // 草稿本上写下:再画到 (100,100)
ctx.beginPath(); // 清空草稿本!前面的都清掉了
ctx.arc(50, 50, 30, 0, Math.PI * 2); // 新的路径
ctx.stroke(); // 只画了 arc,之前的 lineTo 都消失了
构成路径的命令
| 命令 | 说明 |
|---|---|
moveTo(x, y) | 设置路径起点 |
lineTo(x, y) | 从当前点画直线到目标点 |
arc(x, y, r, startAngle, endAngle) | 画圆弧 |
rect(x, y, w, h) | 添加矩形路径 |
quadraticCurveTo() | 二次贝塞尔曲线 |
bezierCurveTo() | 三次贝塞尔曲线 |
closePath() | 闭合路径(连回起点) |
关键点
- 不调用
beginPath(),新路径会叠加到旧路径上 beginPath()只影响路径,不影响任何样式属性
不调用 beginPath 的后果
ctx.strokeStyle = 'red';
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.stroke(); // 画了一条红线
ctx.strokeStyle = 'blue';
ctx.moveTo(0, 100);
ctx.lineTo(100, 0);
ctx.stroke(); // 两条线都变蓝了!因为旧路径还在,被一起重新描边了
第二次
stroke()时,第一条线的路径依然存在,所以两条线都被用当前颜色(蓝色)重新描了一遍。
二、save() / restore() — 只管「状态」
作用
把当前 Canvas 的绘图状态压入 / 弹出状态栈。
ctx.save(); // 把当前所有属性拍一张快照,压入栈顶
ctx.restore(); // 把栈顶的快照弹出来,恢复到当时的属性
保存哪些状态
| 类别 | 属性 |
|---|---|
| 变换 | translate、rotate、scale 等变换矩阵 |
| 裁剪 | clip() 设置的裁剪区域 |
| 线条样式 | lineWidth、lineCap、lineJoin、miterLimit |
| 填充/描边 | fillStyle、strokeStyle |
| 合成 | globalAlpha、globalCompositeOperation |
| 阴影 | shadowBlur、shadowColor、shadowOffsetX、shadowOffsetY |
| 文本 | font、textAlign、textBaseline、direction |
| 滤镜 | filter |
| 图像平滑 | imageSmoothingEnabled |
不保存什么
- 当前路径(
moveTo、lineTo等积累的路径) - 已绘制的内容(画布上的像素)
示例
ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.save(); // 保存状态:红色,宽度2
ctx.strokeStyle = 'blue';
ctx.lineWidth = 10;
ctx.strokeRect(10, 10, 50, 50); // 蓝色粗框
ctx.restore(); // 恢复状态:红色,宽度2
ctx.strokeRect(80, 10, 50, 50); // 红色细框
嵌套使用
save() 可以嵌套调用,restore() 按后进先出(LIFO)顺序弹出:
ctx.save(); // 栈: [状态A]
ctx.save(); // 栈: [状态A, 状态B]
ctx.restore();// 栈: [状态A] ← 回到状态B
ctx.restore();// 栈: [] ← 回到状态A
三、两者关系图解
┌─────────────────────┐ ┌─────────────────────────┐
│ Canvas 状态 │ │ Canvas 路径(草稿本) │
├─────────────────────┤ ├─────────────────────────┤
│ fillStyle: 'red' │ │ moveTo(0, 0) │
│ strokeStyle: 'blue' │ │ lineTo(100, 100) │
│ lineWidth: 5 │ │ arc(50, 50, 30, ...) │
│ transform: [...] │ │ │
│ │ │ │
│ ← save() 保存这些 │ │ ← beginPath() 清空这些 │
└─────────────────────┘ └─────────────────────────┘
save() 只保存左边 ❌ 不保存右边
beginPath() 只清空右边,不影响左边任何属性
案例:
ctx.save(); // ① 保存原始状态
ctx.translate(100, 100); // 移动坐标系
ctx.rotate(Math.PI / 4);
ctx.strokeStyle = 'green';
ctx.lineWidth = 3;
ctx.rect(-25, -25, 50, 50);
ctx.stroke();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.restore(); // ②恢复原始状态
// 坐标系、颜色、线宽都回到 save 之前
ctx.strokeRect(80, 10, 50, 50); // 黑色细框
// ctx.beginPath(); // ③ 开始新路径(跟 save 无关,只是为了不叠加旧路径)
ctx.stroke();
四、两者配合使用的典型场景
刮刮乐 / 橡皮擦效果
class Line {
constructor(ctx) {
this.ctx = ctx;
this.drawing = false;
}
moveTo(x, y) {
this.drawing = true;
this.ctx.save(); // ① 保存原始状态
this.ctx.globalCompositeOperation = 'destination-out'; // 设置为擦除模式
this.ctx.lineWidth = 30;
this.ctx.beginPath(); // ② 开始新路径
this.ctx.moveTo(x, y);
}
lineTo(x, y) {
if (!this.drawing) return;
this.ctx.lineTo(x, y);
this.ctx.stroke();
}
restore() {
if (!this.drawing) return;
this.ctx.restore(); // ③ 恢复原始状态
this.drawing = false;
}
}
绘制旋转/变形的图形
// 画一个旋转的绿色方块
ctx.save(); // 保存原始状态
ctx.translate(100, 100); // 移动坐标系到方块中心
ctx.rotate(Math.PI / 4); // 旋转 45 度
ctx.strokeStyle = 'green';
ctx.lineWidth = 4;
ctx.beginPath(); // 开始新路径(与 save 无关)
ctx.rect(-25, -25, 50, 50); // 以中心为原点的矩形
ctx.stroke();
ctx.restore(); // 回到原始状态
绘制多个不同样式的图形
// 圆
ctx.save();
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.arc(50, 50, 30, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 矩形
ctx.save();
ctx.fillStyle = 'blue';
ctx.beginPath();
ctx.rect(100, 20, 60, 60);
ctx.fill();
ctx.restore();
// 三角形
ctx.save();
ctx.fillStyle = 'green';
ctx.beginPath();
ctx.moveTo(200, 20);
ctx.lineTo(260, 80);
ctx.lineTo(140, 80);
ctx.closePath();
ctx.fill();
ctx.restore();
五、常见误区
误区 1:以为 save 会保存路径
ctx.moveTo(30, 50);
ctx.lineTo(80, 50);
ctx.save(); // 路径不被保存!
ctx.beginPath(); // 清空路径
ctx.moveTo(120, 50);
ctx.lineTo(170, 50);
ctx.restore(); // 路径不会回来!
ctx.stroke(); // 只有右边那条线段被画出
正确做法:如果需要在
save前保留路径,必须先stroke()或fill()把它画出来。
误区 2:以为 beginPath 会恢复样式
ctx.strokeStyle = 'red';
ctx.lineWidth = 10;
ctx.beginPath();
ctx.rect(0, 0, 100, 100);
ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.beginPath(); // beginPath 只清空路径,不恢复属性!
ctx.rect(120, 0, 100, 100);
ctx.strokeStyle; // 还是 'blue',不会变回 'red'
ctx.lineWidth; // 还是 2,不会变回 10
正确做法:需要恢复属性时,用
save()/restore()。
误区 3:忘了 beginPath 导致路径叠加
for (let i = 0; i < 5; i++) {
// 没写 beginPath!每次 stroke 都会把所有历史路径重描一遍
ctx.arc(50 + i * 30, 50, 10, 0, Math.PI * 2);
ctx.stroke();
}
正确做法:循环或多次绘制时,每次画新图形前调用
beginPath()。
六、速查表
| 需求 | 用什么 |
|---|---|
| 清空之前的路径,画新图形 | beginPath() |
| 临时改变颜色/线宽,之后恢复 | save() → 改属性 → restore() |
| 临时旋转/平移/缩放,之后恢复 | save() → 变换 → restore() |
| 临时裁剪区域,之后恢复 | save() → clip() → restore() |
| 保存当前绘制的路径 | ❌ 做不到,路径只能画出来 |
| 撤销画布上已经画好的内容 | ❌ 做不到,需要自己维护状态或重绘 |
案例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body{
margin: 0;
padding: 0;
}
canvas{
display: block;
}
</style>
</head>
<body>
<canvas id="myCanvas"></canvas>
<script>
const canvas = document.getElementById('myCanvas');
canvas.width=385;
canvas.height=188;
const ctx = canvas.getContext('2d');
// 1.beginPath() 作用
// ctx.strokeStyle = 'red';
// ctx.moveTo(0, 0);
// ctx.lineTo(100, 100);
// ctx.stroke(); // 画了一条红线
// ctx.strokeStyle = 'blue';
// ctx.moveTo(0, 100);
// ctx.lineTo(100, 0);
// ctx.stroke(); //// 两条线都变蓝了!因为旧路径还在,被一起重新描边了
// 不 beginPath() → 路径会累积,每次 stroke/fill 都会画全部旧路径
ctx.save(); // ① 保存原始状态
ctx.translate(100, 100); // 移动坐标系
ctx.rotate(Math.PI / 4);
ctx.strokeStyle = 'green';
ctx.lineWidth = 3;
ctx.rect(-25, -25, 50, 50);
ctx.stroke();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.restore(); // ②恢复原始状态
// 坐标系、颜色、线宽都回到 save 之前
ctx.strokeRect(80, 10, 50, 50); // 黑色细框
// ctx.beginPath(); // ③ 开始新路径(跟 save 无关,只是为了不叠加旧路径)
ctx.stroke();
</script>
</body>
</html>