beginPath-vs-save详解

15 阅读5分钟

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(); // 把栈顶的快照弹出来,恢复到当时的属性

保存哪些状态

类别属性
变换translaterotatescale 等变换矩阵
裁剪clip() 设置的裁剪区域
线条样式lineWidthlineCaplineJoinmiterLimit
填充/描边fillStylestrokeStyle
合成globalAlphaglobalCompositeOperation
阴影shadowBlurshadowColorshadowOffsetXshadowOffsetY
文本fonttextAligntextBaselinedirection
滤镜filter
图像平滑imageSmoothingEnabled

不保存什么

  • 当前路径moveTolineTo 等积累的路径)
  • 已绘制的内容(画布上的像素)

示例

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>