wandres.dev
CANVAS: EL CONTEXTO 2D · Empezar a pintar

El modo inmediato: el canvas no recuerda nada

Interiorizar la consecuencia central de la API: no hay objetos que modificar, solo píxeles que ya están puestos, y qué arquitectura exige eso de tu código.

⏱ 18 min

Cuando llamas a ctx.fill(), el navegador modifica píxeles y olvida inmediatamente por qué lo hizo. No queda ningún objeto, ninguna referencia, ninguna forma de volver atrás. Esa amnesia total es lo que define el modo inmediato, y no es un defecto que las librerías vengan a arreglar: es el trato que hace que el canvas sea rápido. Lo que sí es un defecto es no haber escrito el modelo que la amnesia obliga a mantener.

🎯 Al terminar esta lección sabrás
  • Explicar qué queda y qué no queda después de una operación de dibujo en canvas.
  • Reconocer el patrón de estado disperso que produce no tener modelo de escena.
  • Escribir un modelo de escena mínimo con función de pintado pura.
  • Justificar qué capacidades desbloquea ese modelo más allá del dibujo.

Qué queda después de dibujar

Compara las dos secuencias. En el DOM:

const div = document.createElement('div');
div.style.background = 'red';
document.body.appendChild(div);
// Sigue existiendo: div.style.background = 'blue' funciona

En canvas:

ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 50, 50);
// No hay ningun objeto. El rectangulo no existe: existen sus pixeles.

Lo único que sobrevive a una operación de dibujo es el búfer de píxeles y el estado del contexto —los estilos, la matriz, el recorte—. Ni siquiera la ruta actual es fiable, porque beginPath la descarta y muchas operaciones la dejan en un estado que no controlas.

Esto tiene tres consecuencias que se derivan mecánicamente.

No hay eventos por forma. El canvas es un solo elemento; un click te dice dónde pinchó el ratón dentro de él, y averiguar sobre qué figura es problema tuyo.

No hay modificación parcial. Para “cambiar el color de ese círculo” hay que borrar la zona y volver a pintar todo lo que la ocupa, incluidos los elementos que estaban debajo.

No hay orden de apilamiento después del hecho. Lo que se pinta después tapa a lo anterior, y punto. Traer algo al frente significa volver a pintarlo, y para que se vea bien hay que repintar toda la escena en el nuevo orden.

El síntoma: estado disperso

Cuando no se acepta esta realidad, el código evoluciona siempre igual. Empieza así:

// Version 1: parece razonable
let x = 100, y = 100;
function dibujar() {
  ctx.clearRect(0, 0, 400, 400);
  ctx.fillStyle = '#89b4fa';
  ctx.fillRect(x, y, 60, 60);
}

Tres semanas después hay un segundo cuadrado, un color que cambia al pasar el ratón, una selección, una animación y un modo de arrastre. Y el fichero tiene esto:

let x = 100, y = 100, x2 = 220, y2 = 60;
let hover = null, seleccionado = null, arrastrando = false;
let offsetX = 0, offsetY = 0, colorHover = '#f9e2af';
let animando = false, progreso = 0;

Once variables sueltas, y cada función de dibujo tiene que consultarlas todas y saber en qué orden aplicarlas. Añadir un tercer cuadrado significa tocar seis sitios. Esto no es un problema de disciplina: es la consecuencia previsible de que la API no ofrezca dónde poner el estado, así que acaba disperso por el ámbito del módulo.

El modelo de escena, que son treinta líneas

La solución no es una librería. Es aceptar el trato: si el canvas no guarda la escena, la guardas tú, en una estructura de datos explícita, y el dibujo se convierte en una función pura de esa estructura.

<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="500" height="360" style="background:#1e1e2e"></canvas>
<script>
  const ctx = document.getElementById('c').getContext('2d');

  // El estado completo de la aplicacion, en un solo sitio
  const escena = {
    figuras: [
      { id: 1, x: 60,  y: 60,  w: 90, h: 90, color: '#89b4fa' },
      { id: 2, x: 200, y: 120, w: 120, h: 70, color: '#a6e3a1' },
      { id: 3, x: 340, y: 50,  w: 80, h: 140, color: '#f38ba8' },
    ],
    seleccion: null,
  };

  // Pintar: funcion de escena a pixeles, sin estado propio
  function pintar() {
    const { width, height } = ctx.canvas;
    ctx.clearRect(0, 0, width, height);
    for (const f of escena.figuras) {
      ctx.fillStyle = f.color;
      ctx.fillRect(f.x, f.y, f.w, f.h);
      if (f.id === escena.seleccion) {
        ctx.strokeStyle = '#f9e2af';
        ctx.lineWidth = 3;
        ctx.strokeRect(f.x - 1.5, f.y - 1.5, f.w + 3, f.h + 3);
      }
    }
  }

  // Hit testing: recorrer al reves, porque lo ultimo pintado esta encima
  function figuraEn(px, py) {
    for (let i = escena.figuras.length - 1; i >= 0; i--) {
      const f = escena.figuras[i];
      if (px >= f.x && px <= f.x + f.w && py >= f.y && py <= f.y + f.h) return f;
    }
    return null;
  }

  ctx.canvas.addEventListener('pointerdown', e => {
    const r = ctx.canvas.getBoundingClientRect();
    const f = figuraEn(e.clientX - r.left, e.clientY - r.top);
    escena.seleccion = f ? f.id : null;
    pintar();
  });

  pintar();
</script>

Ese código funciona pegado tal cual y tiene una propiedad que el anterior no tenía: el estado está en un sitio y el dibujo no toma decisiones. Añadir una cuarta figura es añadir un objeto al array. Añadir el arrastre es modificar coordenadas del objeto y volver a pintar. Añadir deshacer es guardar copias del array.

Lo que el modelo desbloquea

Aceptar el modo inmediato y escribir la escena aparte no es solo higiene. Habilita cosas concretas que sin ella son imposibles.

Deshacer y rehacer se convierten en una pila de estados serializados, no en una reconstrucción de píxeles.

Persistencia es JSON.stringify(escena). La aplicación puede guardar y cargar sin ningún formato binario.

Exportar a otro formato es escribir una segunda función de pintado que recorra la misma escena y emita SVG en lugar de llamadas al contexto. Esa es la vía para tener impresión vectorial en una aplicación de canvas.

Tests sin navegador: la lógica de la escena —hit testing, selección, transformaciones— se prueba con funciones puras y datos, sin ningún canvas.

Redibujar a cualquier resolución: la misma función con un contexto escalado produce la versión de alta densidad o la de impresión.

Recuperación ante un contexto perdido: cuando el navegador restaura el contexto, basta con volver a llamar a pintar().

El modo inmediato no te libera de tener un árbol: te libera de que lo tenga el navegador

Hay una lectura equivocada muy extendida de todo esto, y es pensar que el modelo de escena tiene que ser un árbol de objetos con métodos draw(), jerarquía y polimorfismo, es decir, reimplementar el DOM en JavaScript. Eso es exactamente lo que hacen las librerías de canvas orientadas a objetos, y es también la razón de que muchas de ellas rindan peor que un canvas desnudo: han recuperado todo el coste del modo retenido —una asignación por nodo, un salto de puntero por dibujo, invalidación en cascada— sin tener las optimizaciones que un navegador lleva veinte años puliendo. La forma que rinde y escala es la contraria: datos planos, código separado. Arrays de estructuras homogéneas, idealmente arrays paralelos de números cuando el volumen es alto, y funciones que los recorren. Un array de cien mil objetos con {x, y, color} ya es lento de recorrer por la indirección; cien mil posiciones en un Float32Array no lo es. El modo inmediato te está dando permiso para diseñar tu estructura de datos según tu problema, en lugar de heredar una genérica. Desperdiciarlo montando un árbol de objetos genérico es quedarte con lo peor de los dos mundos: la complejidad del retenido y el rendimiento del inmediato mal usado.

La regla operativa

De todo lo anterior sale una sola disciplina, y merece la pena escribirla como norma del proyecto: ninguna función de dibujo debe tomar decisiones sobre el estado, y ninguna función de estado debe llamar al contexto.

El dibujo lee la escena y produce píxeles. La interacción lee eventos y modifica la escena. Entre los dos hay una llamada a repintar. Cuando esa separación se respeta, un proyecto de canvas se puede mantener durante años; cuando no, se convierte en el fichero que nadie quiere tocar.

⚔️ Reto práctico

Coge el ejemplo de esta lección y añade arrastre: al pulsar sobre una figura, guardar el desplazamiento entre el punto pulsado y la esquina; al mover el puntero con el botón pulsado, actualizar x e y de la figura y repintar. Hazlo sin añadir ninguna variable fuera del objeto escena. Si lo consigues, has entendido la lección; si no, la variable que se te escapó te dirá exactamente dónde estaba el hueco en el modelo.