【Canvas 2D】day36-save-restore

作者:mario 发布时间: 2026-09-01 阅读量:5 评论数:0

Canvas save/restore 状态栈 — 给 ctx 装上"后悔药"

第 5 周我们吃过两次"状态泄漏"的亏:Day 32 的阴影四件套画完没关,后面每个图形都带光晕;Day 35 画板的橡皮擦(destination-out)结束没恢复,画笔也变成了橡皮。当时的解法是"手动复位每个属性"——能用,但属性一多就繁琐易漏。今天学 Canvas 的正规状态管理机制:save()/restore() 状态栈,一进一出,整组状态瞬间备份/恢复。它是后面三天所有变换操作的安全带,也是工业组态"图元绘制互不污染"的基石。


目录


一、问题的本质:ctx 只有一份"全局状态"

1.1 回顾泄漏现场

// 第 5 周的阴影泄漏现场:
ctx.shadowColor = "rgba(0,255,136,0.8)";
ctx.shadowBlur = 20;
ctx.fillRect(100, 100, 80, 80);     // 这个方块带光晕 ✅

ctx.fillRect(300, 100, 80, 80);     // ❌ 这个也带光晕!阴影是"粘"在 ctx 上的

1.2 ctx 状态机模型

CanvasRenderingContext2D 想象成一位"只有一份工作记忆"的画师:

┌────────────────────────────────────┐
│  ctx(画师的工作台)                  │
│                                    │
│  当前颜料: fillStyle = "#00ff88"    │  ← 只有一份!
│  当前笔刷: lineWidth = 4            │  ← 只有一份!
│  当前特效: shadowBlur = 20          │  ← 只有一份!
│  当前坐标变换: (平移/旋转/缩放)      │  ← 只有一份!
│  当前合成模式: source-over          │  ← 只有一份!
└────────────────────────────────────┘

任何绘制 API(fill/stroke/fillText...)都读取这"一份"状态。
改了状态,后面所有绘制全部受影响 —— 这就是泄漏的根源。

没有保存/恢复机制的话,画 10 个风格各异的图形意味着:每个图形前面手动设置 8+ 个属性、画完再手动恢复 8+ 个属性 —— 代码又长又容易漏。


二、save/restore:栈式状态管理

2.1 基本用法

ctx.save();          // ① 把当前全部状态"拍照",压入栈(画面内容不受影响!)

ctx.shadowColor = "rgba(0,255,136,0.8)";   // ② 随便改状态
ctx.shadowBlur = 20;
ctx.fillStyle = "#00ff88";
ctx.translate(100, 100);
ctx.rotate(Math.PI / 6);

ctx.fillRect(0, 0, 80, 80);                 // ③ 用改过的状态画

ctx.restore();        // ④ 弹栈:状态瞬间回到拍照那一刻
                      //    阴影没了、fillStyle 复原、变换也复原!

ctx.fillRect(300, 100, 80, 80);   // ⑤ 正常方块,无污染 ✅

2.2 三个关键认知

认知 1:save/restore 管的是"状态",不是"画面"

// ⚠ 最常见的误解:以为 save 能撤销绘制内容
ctx.save();
ctx.fillRect(0, 0, 100, 100);   // 画了一个方块
ctx.restore();
// ❌ 方块还在!restore 不清画面 —— 撤销画面要用 ImageData 快照(Day 35 画板的做法)

认知 2:栈是后进先出(LIFO)

ctx.save();     // 栈:[快照A]
ctx.save();     // 栈:[快照A, 快照B]
ctx.restore();  // 弹出 B → 回到 B 时刻   栈:[快照A]
ctx.restore();  // 弹出 A → 回到 A 时刻   栈:[]
ctx.restore();  // ⚠ 空栈上 restore:静默失败(不报错,但也什么都不做)

认知 3:restore 恢复的是"拍照时刻的整组状态",包括变换矩阵

这是本周最重要的伏笔:变换(translate/rotate/scale)也是状态,也被 save/restore 管理。明天的所有变换代码都会包在 save/restore 里。


三、哪些状态会被保存

3.1 会被 save/restore 管理的状态清单

类别 具体属性 第几周学的
描边/填充 strokeStyle、fillStyle Week5 Day30/31
线条 lineWidth、lineCap、lineJoin、miterLimit、lineDashOffset Day 30
阴影 shadowColor、shadowBlur、shadowOffsetX/Y Day 32
合成/透明 globalAlpha、globalCompositeOperation Day 32
文本 font、textAlign、textBaseline、direction Day 33
变换矩阵 translate/rotate/scale 的累积结果 本周 Day 37-38
裁剪区域 clip() 设置的区域 第 7 周
滤镜 ctx.filter = “blur(4px)” Day 42 会用

3.2 不会被保存的

// 画布内容(像素)—— 管理它用 ImageData(Day 39)
// canvas.width / canvas.height —— DOM 属性
// 当前路径(beginPath 开始的那张"草稿纸")—— ⚠ 这是个坑!见第七节坑 3

四、嵌套:栈的真正威力

4.1 为什么需要嵌套?

工业组态的经典场景:整个画面在变换(缩放平移)→ 其中某个设备图标自身还在旋转 → 图标上的报警灯还在闪烁(透明度动画)。三层变换互相独立、互不干扰——靠的就是嵌套 save/restore。

4.2 嵌套的结构模型

// 绘制一个"在大场景变换中自转的设备":
ctx.save();                          // ── 第 1 层:场景变换开始
  ctx.translate(sceneX, sceneY);     //    场景平移
  ctx.scale(sceneZoom, sceneZoom);   //    场景缩放

  ctx.save();                        // ── 第 2 层:设备自身变换开始
    ctx.translate(devX, devY);       //    设备在场景中的位置
    ctx.rotate(devAngle);            //    设备自转

    ctx.save();                      // ── 第 3 层:报警灯特效开始
      ctx.globalAlpha = pulse;       //    闪烁透明度
      ctx.fillStyle = "#ff4d4f";
      ctx.beginPath();
      ctx.arc(0, 0, 6, 0, Math.PI * 2);
      ctx.fill();                    //    画报警灯
    ctx.restore();                   // ── 第 3 层结束:透明度复原

    ctx.fillStyle = "#4a6fa5";       //    设备本体不受闪烁影响
    ctx.fillRect(-15, -15, 30, 30);  //    画设备方块(仍在旋转中)

  ctx.restore();                     // ── 第 2 层结束:设备角度复原

  drawPipe(ctx);                     //    画管线(只受场景变换影响,不受设备自转影响)

ctx.restore();                       // ── 第 1 层结束:场景变换复原

读代码的技巧:遇到 save 就记"入层",遇到 restore 就记"出层"——缩进对齐后,每层状态的作用范围一目了然。

4.3 嵌套变换的叠加原理(预习明天)

第 1 层变换:平移(100,100) + 缩放(2)
第 2 层变换:旋转(45°)

实际画报警灯时的总变换 = 场景变换 ∘ 设备变换 ∘ 灯特效
(矩阵复合:效果层层相乘 —— 明天 Day 37/38 展开)

save/restore 的作用 = 保证每层结束后"卸掉"自己加的变换,
                      不会泄漏到兄弟节点。

五、实战:三层嵌套变换画旋转风车

今天的实战目标:一座风车——底座固定,风车叶轮持续旋转,每个叶片颜色不同且带发光。用三层 save/restore 嵌套组织代码,动画用 Day 34 的 rAF。

5.1 完整代码

// day36-windmill.ts —— 三层嵌套 save/restore 的旋转风车
import { bootCanvas } from "../day29/canvas-boot.js";

const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);

const CX = cssWidth / 2;    // 风车中心
const CY = cssHeight / 2;
let angle = 0;              // 叶轮当前角度(弧度)

/** 绘制底座(静态,不在变换层里) */
function drawBase(): void {
  ctx.fillStyle = "#3a4a6a";
  ctx.beginPath();
  ctx.moveTo(CX - 10, CY);          // 三角形塔身的三个顶点
  ctx.lineTo(CX + 10, CY);
  ctx.lineTo(CX, CY + 120);
  ctx.closePath();
  ctx.fill();
}

/**
 * 绘制叶轮:一根叶片 = save → rotate(第 i 份角度) → 画叶片 → restore
 * 4 根叶片用同一份代码,只差旋转角 —— 这就是变换的复用威力
 */
function drawRotor(): void {
  const bladeColors = ["#00ff88", "#00c6ff", "#ffc53d", "#ff4d4f"];

  for (let i = 0; i < 4; i++) {
    ctx.save();                                  // ── 每根叶片独立的状态空间
      ctx.translate(CX, CY);                     // ① 坐标原点搬到风车中心
      ctx.rotate(angle + (Math.PI / 2) * i);     // ② 转到第 i 根叶片的朝向

      // 从这里开始:原点就是风车中心、x 轴指向叶片方向 —— 画叶片极简单
      ctx.save();                                // ── 叶片内的发光特效层
        ctx.shadowColor = bladeColors[i];        //   阴影颜色 = 叶片颜色
        ctx.shadowBlur = 15;
        ctx.fillStyle = bladeColors[i];
        ctx.beginPath();
        ctx.moveTo(0, 0);                        //   从中心出发
        ctx.lineTo(90, -12);                     //   叶片尖
        ctx.lineTo(90, 12);
        ctx.closePath();
        ctx.fill();                              //   一根带光晕的叶片
      ctx.restore();                             // ── 特效层结束(阴影关闭)

    ctx.restore();                               // ── 叶片层结束(变换复原)
  }

  // 中心轴(不受任何叶片变换影响,单独画)
  ctx.save();
  ctx.translate(CX, CY);
  ctx.fillStyle = "#e6f1ff";
  ctx.beginPath();
  ctx.arc(0, 0, 8, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

// ===== 动画主循环(Day 34 套路)=====
let lastTime = performance.now();

function frame(now: number): void {
  const dt = Math.min((now - lastTime) / 1000, 0.05);
  lastTime = now;

  angle += Math.PI * 0.5 * dt;   // 每秒转 90°(角速度也是"每秒单位",乘 dt)

  ctx.clearRect(0, 0, cssWidth, cssHeight);
  drawBase();
  drawRotor();

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

5.2 代码结构复盘

frame(清屏)
├── drawBase:无变换,直接画
└── drawRotor
    ├── 叶片 i=0:save → translate+rotate → save → 阴影 → 画 → restore → restore
    ├── 叶片 i=1:同上(完全独立的变换空间)
    ├── 叶片 i=2、3:同上
    └── 中心轴:save → translate → 画 → restore

如果没有 save/restore 会怎样? 第 1 根叶片 rotate 后坐标系就歪了,第 2 根叶片在歪的基础上再 rotate……4 根叶片挤成一团。每根叶片"入层画完出层"才互不干扰。


六、save/restore vs 手动复位:选型指南

第 5 周画板用的是"手动复位"(endStroke 里恢复 lineWidth 和合成模式),今天学了栈式管理。什么时候用哪个?

维度 save/restore 手动复位
改动的属性数量 多(3 个以上)划算 少(1-2 个)更轻
嵌套变换 唯一选择(矩阵无法手动"减"回去) 不可行
性能敏感的热循环 有入栈出栈开销(极小,但每帧千次调用时需掂量) 直接赋值最快
代码可读性 结构清晰(缩进即层级) 状态列表显式可见
漏恢复的风险 低(一组一保护) 高(属性多了必漏)

实践准则

// 准则 1:涉及变换(translate/rotate/scale)→ 必须 save/restore(无替代方案)
// 准则 2:临时特效(阴影/合成模式/透明度)→ 优先 save/restore
// 准则 3:每帧热路径中重复上千次的单一属性切换 → 可用直接赋值
//          例:粒子系统里同色批量绘制,直接设置 fillStyle 反而快

// 第 5 周画板的 endStroke 手动复位 lineWidth —— 单属性,保留没问题 ✅
// 但它的 destination-out 恢复,用 save/restore 重写会更稳(见 Day 42 重构)

七、常见坑点与最佳实践

# 症状 解法
1 以为 restore 能撤销画面 restore 后图形还在 画面回滚用 ImageData 快照(Day 35/39)
2 save/restore 不配对 栈越积越深,状态错乱(或空栈静默失效) 写代码时 save/restore 同时敲,中间缩进
3 以为 save 能保存路径 restore 后 beginPath 的草稿没恢复 路径不在栈里! 路径复用要在 restore 前画完,或重建
4 restore 空栈 无报错但状态没恢复(以为恢复了) 开发期可在 DevTools 里 watch 栈深度;严格配对是根治
5 嵌套层数失控 五六层缩进,可读性崩塌 超过 3 层考虑拆函数(每层一个 draw 函数)
6 save 里忘了画任何东西 白白入栈出栈 save 后立即写绘制代码的骨架

坑 3 展开讲(最重要):

// 路径不归 save/restore 管:
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);

ctx.save();
ctx.translate(50, 50);
ctx.restore();

ctx.stroke();   // ⚠ 线画在 (0,0)→(100,100)——路径是 restore【之前】构建的坐标
                //    但 stroke 应用的是【当前】变换吗?——不!路径坐标在构建时就已确定
                //    (准确说:路径点是构建时经当时变换映射进用户空间的)
// 结论:要变换路径,必须"先变换、再建路径",顺序不能反

八、自测挑战

  1. 默写题:不看文档,列出至少 8 个会被 save/restore 管理的 ctx 属性。
  2. 预测题:下面代码执行后,方块是什么颜色?带阴影吗?
ctx.fillStyle = "#ff0000";
ctx.save();
ctx.fillStyle = "#00ff00";
ctx.shadowBlur = 20;
ctx.save();
ctx.fillStyle = "#0000ff";
ctx.restore();
ctx.fillRect(10, 10, 50, 50);
  1. 改错题:下面的代码想画两个不同角度的指针,但结果挤在一起。找出 bug 并修复。
ctx.save();
ctx.translate(100, 100);
ctx.rotate(0.3);
drawNeedle();
ctx.rotate(1.2);      // 想画第二根
drawNeedle();
ctx.restore();
  1. 设计题:给 Day 35 画板的文字工具加"带发光效果"的选项(阴影 + 白色文字),要求效果只作用于文字、不污染后续笔画。写出关键代码段。
  2. 思考题:为什么说"嵌套变换没有 save/restore 就无法实现"?用矩阵复合的思路解释(提示:rotate 之后如何"转回来"?rotate(-angle) 一定安全吗?浮点误差呢?)。

九、总结与知识图谱

Day 36 save/restore 状态栈
├── 本质
│   ├── ctx = 单份全局状态的画师
│   └── 泄漏根源:状态是"粘"的
├── 机制
│   ├── save:整组状态压栈(不含画面/路径!)
│   ├── restore:弹栈复原
│   └── LIFO:嵌套的正确性保证
├── 管理范围
│   ├── 样式类(fill/stroke/line/shadow/text)
│   ├── 行为类(globalAlpha/CompositeOperation/filter)
│   └── 变换矩阵 + 裁剪区域 ⭐(后面三天的安全带)
├── 嵌套实战
│   └── 风车:场景层 → 设备层 → 特效层
└── 选型
    ├── 变换 → 必用栈
    ├── 多属性临时特效 → 优先栈
    └── 热路径单属性 → 直接赋值

明天预告(Day 37):正式进入变换三件套 translate/rotate/scale。今天的风车已经偷偷用了前两个——明天系统拆解:弧度制心算、变换顺序的不可交换性(先转再移 ≠ 先移再转,90% 新手翻车点)、scale 的负数镜像。实战:仪表盘指针动画。

评论