Canvas save/restore 状态栈 — 给 ctx 装上"后悔药"
第 5 周我们吃过两次"状态泄漏"的亏:Day 32 的阴影四件套画完没关,后面每个图形都带光晕;Day 35 画板的橡皮擦(destination-out)结束没恢复,画笔也变成了橡皮。当时的解法是"手动复位每个属性"——能用,但属性一多就繁琐易漏。今天学 Canvas 的正规状态管理机制:save()/restore() 状态栈,一进一出,整组状态瞬间备份/恢复。它是后面三天所有变换操作的安全带,也是工业组态"图元绘制互不污染"的基石。
目录
- 一、问题的本质:ctx 只有一份"全局状态"
- 二、save/restore:栈式状态管理
- 三、哪些状态会被保存
- 四、嵌套:栈的真正威力
- 五、实战:三层嵌套变换画旋转风车
- 六、save/restore vs 手动复位:选型指南
- 七、常见坑点与最佳实践
- 八、自测挑战
- 九、总结与知识图谱
一、问题的本质: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 应用的是【当前】变换吗?——不!路径坐标在构建时就已确定
// (准确说:路径点是构建时经当时变换映射进用户空间的)
// 结论:要变换路径,必须"先变换、再建路径",顺序不能反
八、自测挑战
- 默写题:不看文档,列出至少 8 个会被 save/restore 管理的 ctx 属性。
- 预测题:下面代码执行后,方块是什么颜色?带阴影吗?
ctx.fillStyle = "#ff0000";
ctx.save();
ctx.fillStyle = "#00ff00";
ctx.shadowBlur = 20;
ctx.save();
ctx.fillStyle = "#0000ff";
ctx.restore();
ctx.fillRect(10, 10, 50, 50);
- 改错题:下面的代码想画两个不同角度的指针,但结果挤在一起。找出 bug 并修复。
ctx.save();
ctx.translate(100, 100);
ctx.rotate(0.3);
drawNeedle();
ctx.rotate(1.2); // 想画第二根
drawNeedle();
ctx.restore();
- 设计题:给 Day 35 画板的文字工具加"带发光效果"的选项(阴影 + 白色文字),要求效果只作用于文字、不污染后续笔画。写出关键代码段。
- 思考题:为什么说"嵌套变换没有 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 的负数镜像。实战:仪表盘指针动画。