La pila de estado: save, restore y lo que de verdad guardan
Conocer la lista completa de lo que save y restore preservan, lo que no preservan, y los patrones que evitan el desbalanceo de la pila.
Qué se guarda
ctx.save() mete una copia del estado de dibujo en una pila; ctx.restore() saca la copia de arriba y la aplica. El estado de dibujo incluye:
- La matriz de transformación completa.
- La región de recorte vigente.
strokeStyle,fillStyle,globalAlpha,globalCompositeOperation.lineWidth,lineCap,lineJoin,miterLimit,lineDashOffsety el patrón de guiones.shadowOffsetX,shadowOffsetY,shadowBlur,shadowColor.font,textAlign,textBaseline,direction, y los ajustes tipográficos adicionales del contexto.imageSmoothingEnabled,imageSmoothingQuality.filter.
Y no incluye dos cosas fundamentales:
- La ruta actual. Un
saveseguido de construir una ruta y unrestoredeja la ruta construida. La ruta no es estado de dibujo. - El contenido del búfer.
restoreno deshace nada de lo que hayas pintado.
El recorte: la razón por la que save es obligatorio
De todas las propiedades del estado, la región de recorte es especial porque no hay forma de quitarla directamente. No existe ctx.unclip(). Y clip() solo puede reducir la región vigente: llamarlo dos veces da la intersección de ambas, nunca la unión.
Eso deja exactamente dos maneras de volver a un canvas sin recorte: restore() desde un save() anterior al recorte, o reset(), que además destruye todo lo demás.
ctx.save(); // imprescindiblectx.beginPath();ctx.arc(150, 150, 100, 0, Math.PI * 2);ctx.clip();ctx.drawImage(foto, 0, 0, 300, 300); // recortada en circuloctx.restore(); // unica forma de recuperar el canvasOlvidar ese save es un error del que no se sale: todo lo que dibujes a partir de ahí queda confinado al círculo, para siempre, y el síntoma —“mi canvas dejó de pintar la mitad de las cosas”— apunta a cualquier sitio menos al recorte de cien líneas más arriba.
El desbalanceo
Cada save necesita su restore. Si sobran save, la pila crece sin control y el estado se va acumulando; si sobran restore, las llamadas de más se ignoran silenciosamente cuando la pila está vacía, sin error.
En código sencillo el balance es fácil de mantener. En código con retornos tempranos, excepciones o condicionales, no:
function pintarFigura(ctx, f) { ctx.save(); ctx.translate(f.x, f.y); if (!f.visible) return; // FUGA: sale sin restaurar ctx.fillRect(0, 0, f.w, f.h); ctx.restore();}Una fuga por fotograma en un bucle de animación deja la pila con miles de entradas en un minuto, y el estado acumulado produce un canvas que va derivando: cada figura aparece un poco más desplazada que la anterior.
Las dos defensas que funcionan:
// 1. try/finally: correcto siempre, incluso con excepcionesfunction conEstado(ctx, fn) { ctx.save(); try { fn(ctx); } finally { ctx.restore(); }}conEstado(ctx, c => { c.translate(100, 50); c.rotate(0.4); c.fillRect(-20, -20, 40, 40);});// 2. Comprobacion en desarrollo: contar profundidadlet profundidad = 0;const _save = ctx.save.bind(ctx), _restore = ctx.restore.bind(ctx);ctx.save = () => { profundidad++; _save(); };ctx.restore = () => { profundidad--; _restore(); };// Al final de cada fotograma:if (profundidad !== 0) console.error('Pila desbalanceada:', profundidad);Ese contador de profundidad en desarrollo cuesta nada y detecta la fuga el día que se introduce, en lugar de tres semanas después.
El patrón de capas
El uso idiomático de la pila no es por elemento sino por capa: cada bloque del dibujo se aísla, configura lo que necesita y devuelve el contexto como estaba.
function pintar(ctx, escena, vista) { // Estado base: matriz de densidad, nada mas ctx.setTransform(vista.dpr, 0, 0, vista.dpr, 0, 0); ctx.clearRect(0, 0, vista.ancho, vista.alto); ctx.save(); // capa: mundo ctx.translate(vista.x, vista.y); ctx.scale(vista.zoom, vista.zoom); pintarRejilla(ctx, vista); pintarFiguras(ctx, escena); ctx.restore(); ctx.save(); // capa: interfaz, sin zoom pintarMinimapa(ctx, escena, vista); pintarBarraEstado(ctx, vista); ctx.restore();}Dos pares de save/restore para toda la escena, y cada función interior sabe en qué espacio trabaja. La capa de interfaz no hereda el zoom del mundo, lo que es exactamente lo que quieres: los textos y los controles deben tener el mismo tamaño con cualquier nivel de zoom.
Restaurar sin pila
Hay una alternativa a la pila que conviene conocer para casos concretos: asignar explícitamente todo el estado al principio de cada bloque, sin depender de lo que hubiera.
function configurarEstadoBase(ctx, dpr) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = '#000'; ctx.strokeStyle = '#000'; ctx.lineWidth = 1; ctx.lineCap = 'butt'; ctx.lineJoin = 'miter'; ctx.miterLimit = 10; ctx.setLineDash([]); ctx.lineDashOffset = 0; ctx.shadowBlur = 0; ctx.shadowColor = 'rgba(0,0,0,0)'; ctx.font = '10px sans-serif'; ctx.textAlign = 'start'; ctx.textBaseline = 'alphabetic'; ctx.imageSmoothingEnabled = true; ctx.filter = 'none';}Esa función es fea, larga y tiene un uso muy concreto: garantizar un estado conocido al arrancar un fotograma sin usar reset(), que además borraría el búfer. Es útil en aplicaciones donde varios módulos comparten el mismo contexto y no confías en que todos restauren. Y lo único que no puede restaurar es el recorte, que sigue necesitando restore o reset.