wandres.dev
PATH2D Y OFFSCREENCANVAS · Reutilizar y salir del hilo

OffscreenCanvas: dibujar sin elemento

Usar el canvas que no vive en el DOM: crearlo, dibujar en él, convertirlo en imagen o en blob, y aprovecharlo como capa de caché en el hilo principal.

⏱ 16 min

OffscreenCanvas es un canvas sin elemento: no participa en el layout, no aparece en el DOM y no tiene tamaño CSS. Su interés principal es que existe en los workers, pero también es útil en el hilo principal como capa de caché, donde sustituye ventajosamente al truco clásico de crear un elemento <canvas> que nunca se inserta.

🎯 Al terminar esta lección sabrás
  • Crear un OffscreenCanvas y obtener su contexto.
  • Convertir su contenido en ImageBitmap y en Blob.
  • Usarlo como capa de caché en lugar de un elemento canvas suelto.
  • Reconocer las diferencias de API respecto a un canvas del DOM.

Crear y dibujar

const lienzo = new OffscreenCanvas(512, 512);
const ctx = lienzo.getContext('2d');
ctx.fillStyle = '#89b4fa';
ctx.fillRect(0, 0, 512, 512);

La API de dibujo es idéntica: el contexto es un OffscreenCanvasRenderingContext2D con exactamente los mismos métodos y propiedades. Todo lo aprendido hasta ahora se aplica sin cambios.

Lo que cambia es lo que rodea al dibujo:

No hay tamaño CSS. width y height son las únicas dimensiones que existen; no hay caja, no hay getBoundingClientRect, no hay densidad de pantalla implícita. Si quieres dibujar a la resolución del dispositivo, tienes que pasar tú el factor.

No hay toDataURL. En su lugar hay convertToBlob, que es asíncrono y admite opciones de formato y calidad.

Hay transferToImageBitmap. Extrae el contenido actual como ImageBitmap y deja el canvas vacío, transfiriendo la propiedad de los píxeles sin copiarlos.

// Blob para descargar o subir
const blob = await lienzo.convertToBlob({ type: 'image/webp', quality: 0.9 });

// ImageBitmap para dibujar en otro sitio: sin copia, pero vacia el origen
const bitmap = lienzo.transferToImageBitmap();
ctx.drawImage(bitmap, 0, 0);   // en el canvas de destino
bitmap.close();

transferToImageBitmap es la operación más eficiente para mover el resultado de un canvas fuera de pantalla a otro sitio, precisamente porque no copia. El precio es que el origen queda en blanco, así que solo sirve cuando vas a redibujarlo entero de todos modos.

Como capa de caché

El uso más inmediato, sin workers de por medio, es sustituir el patrón de crear un elemento canvas para cachear algo costoso.

class CapaCache {
  constructor(ancho, alto, dpr = 1) {
    this.dpr = dpr;
    this.lienzo = new OffscreenCanvas(Math.ceil(ancho * dpr), Math.ceil(alto * dpr));
    this.ctx = this.lienzo.getContext('2d');
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.valida = false;
  }

  invalidar() { this.valida = false; }

  actualizar(dibujar) {
    if (this.valida) return;
    const { width, height } = this.lienzo;
    this.ctx.save();
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.ctx.clearRect(0, 0, width, height);
    this.ctx.restore();
    dibujar(this.ctx);
    this.valida = true;
  }

  pintarEn(ctx, x, y, ancho, alto) {
    ctx.drawImage(this.lienzo, x, y, ancho, alto);
  }

  liberar() { this.lienzo.width = this.lienzo.height = 0; }
}

Frente al elemento <canvas> suelto, OffscreenCanvas gana en dos cosas concretas. No crea un nodo del DOM, con lo que no hay riesgo de que algún selector lo alcance ni de que el recolector lo mantenga vivo por una referencia del documento. Y no tiene tamaño intrínseco que pueda influir en ningún layout, eliminando de raíz el bucle de realimentación que produce un canvas dentro de un contenedor flexible.

El caso de uso típico es una capa que cambia poco dentro de una escena que cambia mucho: la rejilla de un editor, el fondo de un gráfico, el terreno de un juego.

const fondo = new CapaCache(ancho, alto, dpr);

function pintar() {
  fondo.actualizar(c => {
    dibujarRejilla(c, ancho, alto);
    dibujarEjes(c, ancho, alto);
  });
  ctx.clearRect(0, 0, ancho, alto);
  fondo.pintarEn(ctx, 0, 0, ancho, alto);   // una copia, en lugar de reconstruir
  dibujarDatos(ctx, escena);
}

Si la rejilla tiene doscientas líneas, la diferencia entre reconstruirla cada fotograma y copiarla es de doscientas operaciones de trazado frente a una de copia.

Generar imágenes sin mostrarlas

OffscreenCanvas es también la forma limpia de producir imágenes que nunca se enseñan: miniaturas, capturas para exportar, texturas procedurales, imágenes para compartir.

async function generarTarjeta(titulo, subtitulo) {
  const lienzo = new OffscreenCanvas(1200, 630);
  const c = lienzo.getContext('2d');

  const g = c.createLinearGradient(0, 0, 1200, 630);
  g.addColorStop(0, '#1e1e2e');
  g.addColorStop(1, '#313244');
  c.fillStyle = g;
  c.fillRect(0, 0, 1200, 630);

  c.fillStyle = '#cdd6f4';
  c.font = '700 72px system-ui';
  c.fillText(titulo, 80, 300);
  c.fillStyle = '#89b4fa';
  c.font = '400 36px system-ui';
  c.fillText(subtitulo, 80, 370);

  return lienzo.convertToBlob({ type: 'image/png' });
}

const blob = await generarTarjeta('Canvas y SVG', 'Guía completa');
const url = URL.createObjectURL(blob);
// ... usar la url y despues URL.revokeObjectURL(url)

Y lo mismo dentro de un worker, con lo que la generación de imágenes deja de bloquear la interfaz por completo.

El OffscreenCanvas del hilo principal no es más rápido, y creer que sí lleva a decisiones equivocadas

Hay un malentendido muy extendido que conviene desmontar: crear un OffscreenCanvas en el hilo principal no hace que el dibujo sea asíncrono ni que salga del hilo principal. El nombre engaña. Es exactamente igual de síncrono y consume exactamente el mismo tiempo del hilo que un canvas normal; lo único que ocurre “fuera de pantalla” es que no se muestra. El beneficio de rendimiento aparece solo cuando el objeto vive dentro de un worker, que es un contexto de ejecución distinto. Esa confusión lleva a dos decisiones erróneas que he visto varias veces. La primera: migrar todos los canvas de caché de una aplicación a OffscreenCanvas esperando una mejora de fluidez, y no obtener ninguna, porque el trabajo sigue en el mismo sitio. La migración sigue siendo buena idea por las razones de higiene del DOM, pero hay que esperar cero mejora de velocidad. La segunda, más grave: intentar generar una imagen grande —una exportación de 4000 por 3000— en el hilo principal con OffscreenCanvas creyendo que no bloqueará, y congelar la pestaña durante dos segundos. Para eso hay que usar un worker de verdad. Hay un matiz que sí es real y que merece conocerse: convertToBlob es asíncrono, y la codificación a PNG o WebP —que es la parte cara de una exportación— puede ejecutarse fuera del hilo principal en varios navegadores. Así que la exportación de una imagen ya dibujada sí se beneficia, aunque el dibujo no. La forma de saber si estás obteniendo el beneficio es la de siempre: mide el hilo principal en el panel de rendimiento y mira si la barra larga sigue ahí.

Detección de soporte

OffscreenCanvas está disponible en los tres motores desde hace varias versiones, pero conviene comprobarlo y tener un camino de reserva, sobre todo si el objetivo incluye navegadores de sistemas antiguos:

function crearLienzo(ancho, alto) {
  if (typeof OffscreenCanvas !== 'undefined') {
    return new OffscreenCanvas(ancho, alto);
  }
  const c = document.createElement('canvas');
  c.width = ancho; c.height = alto;
  return c;
}

Como ambos objetos son fuentes válidas para drawImage y su contexto 2D tiene la misma API, el resto del código no necesita saber cuál le tocó. La única diferencia práctica es convertToBlob frente a toBlob, que tienen firmas distintas —una devuelve promesa y la otra recibe callback— y conviene envolver:

function aBlob(lienzo, opciones) {
  if (lienzo.convertToBlob) return lienzo.convertToBlob(opciones);
  return new Promise(r => lienzo.toBlob(r, opciones?.type, opciones?.quality));
}