El elemento canvas y getContext
Montar un canvas correctamente desde cero: el elemento, sus dos tamaños, la obtención del contexto y las reglas del ciclo de vida que nadie documenta.
El elemento <canvas> tiene una superficie de API minúscula —dos atributos, tres métodos— y sin embargo la mitad de los problemas de un proyecto de canvas nacen en esas primeras líneas. Los atributos width y height no son lo que parecen, getContext tiene reglas de exclusividad que no se pueden deshacer, y el elemento arranca con un tamaño por defecto que casi nunca es el que quieres.
- Distinguir los atributos
widthyheightdel tamaño CSS del elemento. - Obtener un contexto 2D y explicar por qué un canvas solo admite un tipo de contexto.
- Enumerar los efectos secundarios de asignar
widthoheight. - Escribir el arranque mínimo correcto de un canvas, con su comprobación de errores.
El elemento y sus dos tamaños
El marcado mínimo es este:
<canvas id="lienzo" width="600" height="400">
Tu navegador no dibuja este gráfico. Aquí va la descripción alternativa.
</canvas>
Los atributos width y height no son CSS. Son las dimensiones del búfer de píxeles, en píxeles del búfer, y su valor por defecto es 300 por 150 si no los pones. El tamaño con el que el elemento se muestra en la página lo decide CSS, exactamente igual que con una <img>, y si no pones ninguna regla el elemento se muestra a su tamaño intrínseco, que es el del búfer.
Esa dualidad es la fuente del problema más común del canvas, y merece toda una lección aparte, pero el hecho básico hay que fijarlo ya: son dos tamaños independientes, y cuando no coinciden el navegador escala el búfer para rellenar la caja, con la misma interpolación que aplicaría a una imagen.
/* Esto NO cambia el numero de pixeles del buffer: lo estira */
#lienzo { width: 1200px; height: 800px; }
El contenido que pones dentro del elemento es el contenido de reserva. No se pinta nunca en un navegador que soporte canvas —todos lo hacen—, pero se expone al árbol de accesibilidad y aparece en algunas conversiones a texto. No lo dejes vacío.
getContext y la regla de exclusividad
El elemento no dibuja nada por sí mismo. Todo el dibujo pasa por un objeto de contexto:
const canvas = document.getElementById('lienzo');
const ctx = canvas.getContext('2d');
getContext tiene tres comportamientos que hay que conocer.
Devuelve siempre el mismo objeto. Llamarlo cien veces con "2d" sobre el mismo canvas devuelve exactamente la misma instancia. No hay coste en volver a pedirlo, aunque tampoco hay razón para hacerlo.
Un canvas solo admite un tipo de contexto en toda su vida. Una vez que has pedido "2d", pedir "webgl" devuelve null para siempre, y al revés. No hay forma de liberar un contexto y pedir otro. Si necesitas ambos modelos, necesitas dos elementos.
Puede devolver null. Aparte del caso anterior, ocurre si el navegador no puede crear el búfer: canvas demasiado grande, memoria agotada, contexto perdido. No es frecuente pero tampoco es imposible, y un ctx nulo produce un error críptico varias líneas más abajo.
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('No se pudo obtener un contexto 2D en este canvas');
}
El segundo parámetro de getContext es un objeto de atributos que condicionan cómo se crea el búfer. Merecen su propia lección; por ahora basta saber que se pasan solo en la primera llamada: en las siguientes se ignoran porque el contexto ya existe.
Los efectos secundarios de cambiar el tamaño
Esta es la parte que sorprende. Asignar canvas.width o canvas.height —incluso asignando el mismo valor que ya tenía— hace tres cosas a la vez:
- Redimensiona el búfer.
- Lo borra entero a negro transparente.
- Restablece todo el estado del contexto a sus valores por defecto: la matriz de transformación vuelve a la identidad, la región de recorte desaparece,
fillStylevuelve a negro,lineWidtha 1, se vacía la pila desave, y se descarta la ruta actual.
ctx.fillStyle = '#f38ba8';
ctx.scale(2, 2);
canvas.width = canvas.width; // no cambia el tamano, pero resetea todo
console.log(ctx.fillStyle); // '#000000'
Ese truco de reasignar el ancho se ha usado durante años como forma rápida de limpiar un canvas. Funciona, pero es una operación mucho más pesada que clearRect porque reasigna el búfer, y además destruye el estado, lo que hace que sea una fuente inagotable de bugs en código que usa transformaciones.
Desde hace varias versiones existe una forma explícita y limpia de hacer lo mismo sin tocar el tamaño:
ctx.reset(); // borra el bitmap y restablece todo el estado
reset() está disponible en los tres motores desde 2023 y es lo que quieres cuando de verdad necesitas volver al punto de partida. Para limpiar solo los píxeles conservando el estado, clearRect sigue siendo la herramienta.
El canvas 2D acelerado por GPU vive en memoria de vídeo, y esa memoria se puede perder: cuando el sistema operativo restablece el controlador gráfico, cuando el navegador entra en modo de ahorro, cuando otra aplicación acapara la GPU, o cuando el sistema decide reclamar recursos de una pestaña en segundo plano. Durante mucho tiempo el canvas 2D no tenía forma de avisar y el resultado era un canvas que se quedaba en blanco sin ningún error. Hoy el elemento emite dos eventos, contextlost y contextrestored, que se pueden escuchar exactamente igual que en WebGL. El primero se puede cancelar con preventDefault() para pedir que el navegador intente restaurarlo; tras el segundo, el búfer está vacío y el estado del contexto ha vuelto a los valores por defecto, así que hay que volver a dibujar la escena entera desde el modelo. Y aquí es donde se ve por qué el modelo de escena no es un lujo arquitectónico: si tu estado vive en los píxeles, un contexto perdido es una pérdida de datos irrecuperable. Si vive en un array de objetos, es una llamada a tu función de pintado. La escena hay que guardarla, y esta es una razón más, no la principal, pero sí la más silenciosa.
canvas.addEventListener('contextlost', e => { e.preventDefault(); parar(); });
canvas.addEventListener('contextrestored', () => { configurar(); pintar(); });El arranque mínimo correcto
Juntando todo, este es el esqueleto que conviene tener memorizado. Funciona pegado tal cual.
<!doctype html>
<meta charset="utf-8">
<style>
html, body { margin: 0; background: #1e1e2e; }
#lienzo { display: block; width: 600px; height: 400px; }
</style>
<canvas id="lienzo" width="600" height="400">
Un cuadrado azul sobre fondo oscuro.
</canvas>
<script>
const canvas = document.getElementById('lienzo');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Sin contexto 2D');
canvas.addEventListener('contextlost', e => e.preventDefault());
canvas.addEventListener('contextrestored', pintar);
function pintar() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#89b4fa';
ctx.fillRect(100, 80, 400, 240);
ctx.strokeStyle = '#cba6f7';
ctx.lineWidth = 4;
ctx.strokeRect(100, 80, 400, 240);
}
pintar();
</script>
Fíjate en el display: block de la regla CSS. Un <canvas> es un elemento en línea por defecto, lo que significa que se alinea sobre la línea base del texto y deja unos pocos píxeles de hueco debajo. Es un detalle cosmético que desconcierta a mucha gente y se arregla con display: block o con vertical-align: bottom.
Y fíjate en que el ancho y alto CSS coinciden exactamente con los atributos. Mientras coincidan y el dispositivo tenga densidad 1, todo se ve bien. En cuanto una de esas dos condiciones deja de cumplirse, el canvas se ve borroso, y esa es una historia que tiene su propio nivel.
Añade al ejemplo un botón que llame a ctx.reset() y otro que llame a ctx.clearRect(0, 0, canvas.width, canvas.height). Antes de pulsarlos, aplica ctx.translate(50, 50) y vuelve a pintar. Observa qué hace cada botón con la traslación: uno la conserva y el otro la elimina. Esa diferencia es el resumen de la lección.