wandres.dev
RESOLUCIÓN Y DPI · El canvas nítido en pantallas retina

ResizeObserver y devicePixelContentBoxSize

Hacer el redimensionado bien de verdad: observar el tamaño real del elemento en píxeles del dispositivo, sin depender del evento resize ni de leer devicePixelRatio.

⏱ 19 min

El evento resize de la ventana solo se dispara cuando cambia la ventana, y el tamaño de un canvas depende de muchas más cosas: un panel que se abre, una barra lateral que se pliega, un contenedor con consultas de contenedor, un cambio de fuente que refluye la página. ResizeObserver resuelve eso, y su caja de píxeles del dispositivo resuelve además el problema del redondeo que hace que la solución con devicePixelRatio no sea exacta.

🎯 Al terminar esta lección sabrás
  • Usar ResizeObserver para reaccionar al tamaño real del canvas venga de donde venga el cambio.
  • Explicar qué aporta devicePixelContentBoxSize frente a multiplicar por devicePixelRatio.
  • Escribir el patrón completo con detección de soporte y respaldo.
  • Evitar el error de bucle de observación y el aviso de la consola que lo delata.

Por qué no basta con el evento resize

window.resize avisa de un cambio en el tamaño de la ventana. El tamaño de un canvas puede cambiar por al menos seis motivos más:

  • Un contenedor que cambia por interacción: un panel plegable, un divisor arrastrable, una pestaña.
  • Una regla de CSS que entra en juego por una consulta de contenedor.
  • La carga de una fuente que cambia el reflujo de la página.
  • Un elemento hermano que aparece o desaparece.
  • Un cambio de contenido en un ancestro con tamaño automático.
  • Una animación de CSS sobre el tamaño de un contenedor.

En todos esos casos el canvas cambia de tamaño y el evento no se dispara. El resultado es un canvas estirado hasta que el usuario mueva la ventana, que es exactamente el bug que aparece en producción y no en desarrollo.

ResizeObserver observa un elemento y notifica cuando su caja cambia, sea cual sea la causa.

const ro = new ResizeObserver(entradas => {
  for (const entrada of entradas) {
    // entrada.target es el elemento; las cajas vienen en las propiedades
  }
});
ro.observe(canvas);

Las tres cajas que ofrece

Cada entrada del observador expone tres medidas, y la tercera es la interesante.

contentBoxSize es la caja de contenido en píxeles CSS. Es lo que mide el contenido sin relleno ni borde.

borderBoxSize es la caja de borde en píxeles CSS, incluyendo relleno y borde.

devicePixelContentBoxSize es la caja de contenido en píxeles reales del dispositivo, ya redondeada exactamente como el navegador la va a componer.

Esa tercera medida es la que resuelve el problema que la solución basada en devicePixelRatio no puede resolver: el redondeo lo hace el navegador, no tú.

Cuando calculas Math.round(rect.width * dpr) estás replicando a mano una decisión que el motor de composición ya tomó, y no siempre coincides. La caja de contenido de un elemento cuyo ancho CSS es 300,4 píxeles con ratio 2 podría componerse sobre 600 o sobre 601 píxeles físicos según cómo caiga respecto al origen del elemento y qué haga el subpíxel de layout. Con devicePixelContentBoxSize recibes el número exacto, sin conjeturas y sin necesidad de leer devicePixelRatio para nada.

La consecuencia visible es que las líneas de un píxel salen nítidas siempre, no solo cuando los números salen redondos.

Pedir la caja correcta al observar

devicePixelContentBoxSize no se recibe por defecto: hay que pedirla al observar, y esa petición lanza una excepción en navegadores que no la soportan. Ese comportamiento, que parece incómodo, es en realidad el mecanismo de detección de características.

function observarPixelesReales(elemento, callback) {
  const ro = new ResizeObserver(entradas => {
    for (const e of entradas) {
      const caja = e.devicePixelContentBoxSize?.[0];
      if (caja) {
        callback(caja.inlineSize, caja.blockSize, true);
      } else {
        // Respaldo: caja de contenido en pixeles CSS por el ratio actual
        const c = e.contentBoxSize?.[0];
        const dpr = window.devicePixelRatio || 1;
        const w = c ? c.inlineSize : e.contentRect.width;
        const h = c ? c.blockSize  : e.contentRect.height;
        callback(Math.round(w * dpr), Math.round(h * dpr), false);
      }
    }
  });

  try {
    ro.observe(elemento, { box: 'device-pixel-content-box' });
  } catch {
    ro.observe(elemento, { box: 'content-box' });
  }
  return () => ro.disconnect();
}

El try/catch alrededor de observe es el patrón estándar y no hay otro más limpio: no existe ninguna propiedad que consultar para saber si la caja está soportada. Y fíjate en que el respaldo también funciona si el navegador acepta la opción pero no rellena la propiedad en alguna entrada concreta, que es un caso que ocurre.

Un detalle sobre los nombres: las cajas usan inlineSize y blockSize, no width y height, porque están expresadas en el eje lógico del elemento. En un texto horizontal de izquierda a derecha, inlineSize es el ancho y blockSize el alto; con writing-mode: vertical-rl están intercambiados. Para un canvas eso rara vez importa, pero si tu aplicación soporta escritura vertical hay que tenerlo presente.

El patrón completo

Poniéndolo todo junto, este es el canvas responsivo definitivo. Funciona pegado tal cual, se adapta a cualquier cambio de tamaño venga de donde venga, y es nítido en cualquier densidad y con cualquier zoom.

<!doctype html>
<meta charset="utf-8">
<style>
  html, body { margin: 0; height: 100%; background: #1e1e2e; color: #cdd6f4;
               font: 14px system-ui; }
  .panel { display: flex; height: 100vh; }
  .barra { width: 240px; background: #181825; padding: 1rem; transition: width .3s; }
  .barra.plegada { width: 60px; }
  .lienzo { position: relative; flex: 1; min-width: 0; }
  .lienzo > canvas { position: absolute; inset: 0; width: 100%; height: 100%;
                     display: block; }
  button { font: inherit; }
</style>
<div class="panel">
  <aside class="barra" id="barra"><button id="b">Plegar</button></aside>
  <div class="lienzo"><canvas id="c">Rejilla de referencia.</canvas></div>
</div>
<script>
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  let anchoLogico = 0, altoLogico = 0, exacto = false;

  function alRedimensionar(wFisico, hFisico, esExacto) {
    exacto = esExacto;
    if (canvas.width !== wFisico || canvas.height !== hFisico) {
      canvas.width = Math.max(1, wFisico);
      canvas.height = Math.max(1, hFisico);
    }
    const rect = canvas.getBoundingClientRect();
    anchoLogico = rect.width;
    altoLogico = rect.height;
    // Escala derivada del cociente real, no de devicePixelRatio
    ctx.setTransform(canvas.width / anchoLogico, 0, 0,
                     canvas.height / altoLogico, 0, 0);
    pintar();
  }

  function pintar() {
    ctx.clearRect(0, 0, anchoLogico, altoLogico);
    ctx.strokeStyle = '#45475a';
    ctx.lineWidth = 1;
    for (let x = 0.5; x < anchoLogico; x += 20) {
      ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, altoLogico); ctx.stroke();
    }
    for (let y = 0.5; y < altoLogico; y += 20) {
      ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(anchoLogico, y); ctx.stroke();
    }
    ctx.fillStyle = '#a6e3a1';
    ctx.font = '16px system-ui';
    ctx.fillText(
      `${canvas.width}×${canvas.height} px reales · ` +
      `${anchoLogico.toFixed(1)}×${altoLogico.toFixed(1)} CSS · ` +
      (exacto ? 'devicePixelContentBox' : 'respaldo con dpr'), 16, 30);
  }

  const ro = new ResizeObserver(entradas => {
    for (const e of entradas) {
      const caja = e.devicePixelContentBoxSize?.[0];
      if (caja) alRedimensionar(caja.inlineSize, caja.blockSize, true);
      else {
        const c = e.contentBoxSize?.[0];
        const dpr = window.devicePixelRatio || 1;
        alRedimensionar(Math.round((c?.inlineSize ?? e.contentRect.width) * dpr),
                        Math.round((c?.blockSize ?? e.contentRect.height) * dpr), false);
      }
    }
  });
  try { ro.observe(canvas, { box: 'device-pixel-content-box' }); }
  catch { ro.observe(canvas, { box: 'content-box' }); }

  document.getElementById('b').onclick = () =>
    document.getElementById('barra').classList.toggle('plegada');
</script>

Pulsa el botón: la barra lateral se pliega con una transición de CSS y el canvas se reajusta en cada fotograma de la animación, sin que ningún evento resize se haya disparado nunca. Cambia el zoom del navegador: la etiqueta muestra un número distinto de píxeles reales y la rejilla sigue siendo de líneas de un píxel exacto.

Fíjate en que la matriz se calcula como canvas.width / anchoLogico en lugar de usar devicePixelRatio. Es más robusto: si el observador te dio la caja de píxeles reales, ese cociente es la escala verdadera, incluidas las transformaciones CSS de los ancestros que devicePixelRatio desconoce.

El aviso de bucle de ResizeObserver no es un warning cualquiera: es una pérdida de fotogramas

Si al ejecutar código de este tipo ves en consola ResizeObserver loop completed with undelivered notifications, no lo ignores. Significa que las notificaciones de un ciclo provocaron cambios de layout que generaron nuevas notificaciones, y el navegador tuvo que cortar el ciclo y aplazar lo pendiente al fotograma siguiente. Visualmente se traduce en un parpadeo o en un fotograma con el canvas del tamaño anterior. La causa habitual en canvas es concreta: asignar canvas.width cambia el tamaño intrínseco del elemento reemplazado, y si el layout del contenedor depende de ese tamaño intrínseco, el observador vuelve a dispararse. Es el mismo bucle de realimentación de la lección anterior, ahora con un mecanismo que lo hace visible en consola en lugar de silencioso. La defensa es la misma y es estructural: el canvas debe estar posicionado en absoluto dentro de un contenedor cuyo tamaño no dependa de él. Hay una segunda causa menos conocida: observar el propio canvas cuando su tamaño CSS está en porcentaje del padre y el padre tiene tamaño automático. En ese caso conviene observar el contenedor y no el canvas, porque el contenedor sí tiene un tamaño que no depende del búfer. Y una tercera, que aparece solo en producción: dibujar de forma síncrona dentro del callback del observador cuando el dibujo modifica el DOM —por ejemplo, actualizando una leyenda en HTML—. Si el dibujo toca el DOM, aplázalo a requestAnimationFrame y deja el callback del observador limitado a guardar dimensiones.

Cuándo pintar

Queda una decisión de diseño: ¿pintar dentro del callback del observador o aplazarlo?

Pintar dentro es correcto y da el resultado más inmediato, y es lo que hace el ejemplo. Es la opción adecuada cuando el pintado es rápido y no toca el DOM.

Aplazar a requestAnimationFrame es lo correcto cuando el pintado es caro, cuando puede haber varios cambios en la misma ráfaga, o cuando el pintado modifica otros elementos. El patrón es marcar un indicador y pintar una sola vez por fotograma:

let pendiente = false;
function solicitarPintado() {
  if (pendiente) return;
  pendiente = true;
  requestAnimationFrame(() => { pendiente = false; pintar(); });
}

Con eso, veinte notificaciones seguidas durante una animación de layout producen un solo repintado por fotograma, que es lo máximo que la pantalla puede mostrar de todos modos.