HTML Canvas save restore 状态保存与恢复新手必读
许多初接触 Canvas 的开发者常对 save() 和 restore() 这对方法产生误解,认为它们能“撤销”已绘制的图形。实际上,这两个方法管理的并非像素,而是绘图上下文(即“画笔”)本身的状态。简单来说,它们负责控制“怎么画”,而非“画了什么”。若不成对使用,可能导致后续样式或位置错乱,严重
许多初接触 Canvas 的开发者常对 save() 和 restore() 这对方法产生误解,认为它们能“撤销”已绘制的图形。实际上,这两个方法管理的并非像素,而是绘图上下文(即“画笔”)本身的状态。简单来说,它们负责控制“怎么画”,而非“画了什么”。若不成对使用,可能导致后续样式或位置错乱,严重时甚至抛出 DOMException: The operation is not supported 这样的运行时错误。
save() 和 restore() 保存/恢复哪些状态?
每次调用 save(),它都会将绘图上下文(CanvasRenderingContext2D)当前所有可变属性打包,压入一个内部状态栈。而 restore() 则从栈顶弹出一个状态包,并全量应用到当前上下文。
这个“状态包”具体包含以下四大类内容:
- 变换矩阵:由
translate()、rotate()、scale()、transform()等变换操作累积的效果。 - 裁剪路径:通过
clip()方法设置的绘制区域限制。 - 样式属性:如填充色
fillStyle、描边色strokeStyle、透明度globalAlpha、线宽lineWidth、字体font、文本对齐方式textAlign、阴影偏移shadowOffsetX等。 - 合成模式:即
globalCompositeOperation,决定新图形如何与已有内容叠加。
需要明确的是,以下内容不会被保存:
- 已绘制的图形:通过
fillRect()、stroke()等方法画到 Canvas 上的像素,一旦落笔便无法通过restore()擦除或回退。 - Canvas 元素尺寸:
canvas标签的width和height属性不受影响。 - 像素数据:通过
getImageData()获取或putImageData()放置的像素数据,与状态栈无关。
为什么 restore() 会报 DOMException?
此错误的根本原因只有一个:在状态栈为空时调用了 restore()。可以这样理解:每次 save() 向栈中放入一个盒子,restore() 则取出最上方的盒子。若栈已空而仍尝试取出,系统便会报错。
实际开发中,以下情况最易导致栈空错误:
- 数量不匹配:
restore()调用次数多于save()。例如只调用一次save(),却连续调用三次restore()。 - 条件分支遗漏:在
if-else或switch等分支中,仅某个分支调用了save(),但所有分支出口都调用restore(),导致某些路径下restore()缺少对应的save()。 - 异步调用陷阱:在
setTimeout、requestAnimationFrame或事件回调等异步函数中调用restore(),而外层同步代码可能已执行了restore(),导致栈状态提前被清空。
调试此类问题时,可借助 Chrome 或 Firefox 开发者工具中的 Canvas Inspector 功能,它能实时显示当前上下文的状态栈深度,帮助快速定位 save() 与 restore() 是否配对。
嵌套 save/restore 的典型使用场景
该机制最经典的应用是模块化绘制。例如,封装一个绘制可旋转、可缩放图标的函数,并希望该函数的绘制过程完全独立,不影响调用前后的任何绘制状态。此时 save() 和 restore() 便派上用场。
function drawRotatedIcon(ctx, x, y, angle, scale) {
ctx.save(); // 保存调用函数前的所有状态
ctx.translate(x, y); // 将原点移动到图标中心
ctx.rotate(angle);
ctx.scale(scale, scale);
ctx.fillStyle = '#333';
ctx.fillRect(-10, -10, 20, 20); // 以新原点为中心绘制图标
ctx.restore(); // 恢复所有状态,后续绘制不受影响
}
使用这种模式时需要注意以下几点:
- 成对出现:一组
save()和restore()最好在同一个函数作用域内成对出现,如同给代码块加上一对括号,确保状态隔离。 - 避免滥用:不要为单条绘图指令包裹一层状态保存(如
ctx.save(); ctx.fillRect(...); ctx.restore();),这只会增加额外开销而无实际意义。 - 考虑替代方案:若仅需重置变换矩阵,使用
ctx.setTransform(1, 0, 0, 1, 0, 0)直接重置为单位矩阵,性能开销通常比调用restore()(会恢复所有状态)更小。
容易被忽略的裁剪路径陷阱
clip() 方法设置的裁剪区域属于绘图状态的一部分,会被 save() 和 restore() 管理,但它的行为有些特殊,容易令人困惑:
- 可恢复性:调用
clip()后再执行save(),裁剪区域会被保存;随后调用restore()时,裁剪区域也会被还原或清除。 - 路径依赖:
clip()作用于当前路径。若在调用clip()前未使用beginPath()开启新路径,则旧路径(可能来自之前绘图操作)也会一并用于裁剪,导致裁剪范围与预期不符。 - 非叠加性:多次调用
clip()并不会使裁剪区域叠加(取交集),而是用最新路径完全替换当前的裁剪区域。
一个安全的裁剪使用示例如下:
ctx.save(); ctx.beginPath(); // 关键:开启新路径 ctx.rect(50, 50, 100, 100); ctx.clip(); // 设置裁剪区域为该矩形 // 此后所有绘制,仅落在 (50,50,100,100) 矩形区域内的部分可见 ctx.fillStyle = 'red'; ctx.fillRect(0, 0, 200, 200); // 实际只有右下角 50x50 区域显示红色 ctx.restore(); // 恢复状态,裁剪区域失效,后续绘制恢复正常
真正复杂的情况往往发生在裁剪、变换和样式混合使用时。此时 save() 的调用时机至关重要——它决定了希望“冻结”状态的哪一层。许多初学者在此踩坑,便是在设置 clip() 之前忘了 save(),导致在 restore() 之后,裁剪限制依然生效,影响后续所有绘制。
