Shadow DOM declarativo

El Shadow DOM declarativo es una función estándar de la plataforma web que se admite en Chrome desde la versión 90. Ten en cuenta que la especificación de esta función cambió en 2023 (incluido un cambio de nombre de shadowroot a shadowrootmode) y las versiones estandarizadas más actualizadas de todas las partes de la función se lanzaron en la versión 124 de Chrome.

Navegadores compatibles

  • Chrome: 111
  • Edge: 111
  • Firefox: 123
  • Safari: 16.4.

Origen

Shadow DOM es uno de los tres estándares de componentes web, que se redondea con plantillas de HTML y elementos personalizados. Shadow DOM proporciona una forma de definir el alcance de los estilos de CSS en un subárbol de DOM específico y aislar ese subárbol del resto del documento. El elemento <slot> nos brinda una forma de controlar dónde se deben insertar los elementos secundarios de un elemento personalizado dentro de su árbol de sombras. Estas funciones combinadas permiten un sistema para compilar componentes reutilizables independientes que se integran sin problemas en aplicaciones existentes, como un elemento HTML integrado.

Hasta ahora, la única forma de usar shadow DOM era construir una raíz de sombra con JavaScript:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

Una API imperativa como esta funciona bien para la renderización del cliente: los mismos módulos de JavaScript que definen nuestros elementos personalizados también crean sus shadow Roots y configuran su contenido. Sin embargo, muchas aplicaciones web necesitan renderizar contenido del servidor o HTML estático en el tiempo de compilación. Esto puede ser una parte importante para ofrecer una experiencia razonable a los visitantes que no pueden ejecutar JavaScript.

Las justificaciones para la renderización del servidor (SSR) varían de un proyecto a otro. Algunos sitios web deben proporcionar HTML totalmente funcional renderizado por servidor para cumplir con los lineamientos de accesibilidad, otros optan por ofrecer una experiencia de referencia sin JavaScript como una forma de garantizar un buen rendimiento en dispositivos o conexiones lentas.

Históricamente, ha sido difícil usar Shadow DOM en combinación con la renderización del servidor porque no había una forma integrada de expresar raíces secundarias en el HTML generado por el servidor. También hay consecuencias de rendimiento cuando se adjuntan raíces de sombra a elementos DOM que ya se renderizaron sin ellas. Esto puede provocar que el diseño cambie después de que se cargue la página o que se muestre temporalmente un destello de contenido sin diseño ("FOUC") mientras se cargan los archivos de diseño de la raíz de sombra.

El Shadow DOM declarativo (DSD) quita esta limitación y lleva Shadow DOM al servidor.

Cómo compilar una shadow Root declarativa

Una raíz en las sombras declarativa es un elemento <template> con un atributo shadowrootmode:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

El analizador de HTML detecta un elemento de plantilla con el atributo shadowrootmode y lo aplica de inmediato como la raíz en las sombras de su elemento superior. Cargar el lenguaje de marcado HTML puro del ejemplo anterior genera el siguiente árbol del DOM:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

Esta muestra de código sigue las convenciones del panel Elements de las Herramientas para desarrolladores de Chrome para mostrar contenido del Shadow DOM. Por ejemplo, el carácter representa el contenido de Light DOM con ranuras.

Esto nos brinda los beneficios del encapsulamiento y la proyección de ranuras de Shadow DOM en HTML estático. No se necesita JavaScript para producir todo el árbol, incluida la raíz de sombra.

Hidratación de componentes

El Shadow DOM declarativo se puede usar por sí solo como una forma de encapsular estilos o personalizar la ubicación de los elementos secundarios, pero es más potente cuando se usa con elementos personalizados. Los componentes creados con elementos personalizados se actualizan automáticamente desde el HTML estático. Con la introducción del Shadow DOM declarativo, ahora es posible que un elemento personalizado tenga una shadow root antes de que se actualice.

Un elemento personalizado que se actualiza desde HTML y que incluye una shadow root declarativa ya tendrá esa shadow root adjunta. Esto significa que el elemento ya tendrá una propiedad shadowRoot disponible cuando se cree una instancia, sin que tu código la cree de forma explícita. Es mejor verificar this.shadowRoot en busca de cualquier raíz de sombra existente en el constructor de tu elemento. Si ya hay un valor, el código HTML de este componente incluye un elemento raíz de sombra declarativo. Si el valor es nulo, no había un elemento raíz de sombra declarativo en el HTML o el navegador no es compatible con el Shadow DOM declarativo.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

Los elementos personalizados existen desde hace tiempo y, hasta ahora, no había razón para verificar si había un elemento raíz en sombra existente antes de crear uno con attachShadow(). Shadow DOM declarativo incluye un pequeño cambio que permite que los componentes existentes funcionen a pesar de lo siguiente: llamar al método attachShadow() en un elemento con un Shadow Root declarativo existente no arrojará un error. En su lugar, se vacía y se muestra la raíz paralela declarativa. Esto permite que los componentes más antiguos no compilados para el Shadow DOM declarativo continúen funcionando, ya que las raíces declarativas se conservan hasta que se crea un reemplazo imperativo.

En el caso de los elementos personalizados creados recientemente, una nueva propiedad ElementInternals.shadowRoot proporciona una forma explícita de obtener una referencia al elemento raíz de sombra declarativo existente, tanto abierto como cerrado. Se puede usar para verificar y usar cualquier shadow root declarativa, y volver a attachShadow() en los casos en los que no se proporcionó una.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

Una sombra por raíz

Un elemento raíz de sombra declarativo solo se asocia con su elemento superior. Esto significa que las shadow roots siempre se colocan con su elemento asociado. Esta decisión de diseño garantiza que las shadow roots se puedan transmitir como el resto de un documento HTML. También es conveniente para la creación y generación, ya que agregar una shadow root a un elemento no requiere mantener un registro de shadow root existentes.

La desventaja de asociar raíces de sombra con su elemento superior es que no es posible que se inicialicen varios elementos desde el mismo <template> de raíz de sombra declarativa. Sin embargo, es poco probable que esto sea importante en la mayoría de los casos en los que se usa Shadow DOM declarativo, ya que el contenido de cada shadow root rara vez es idéntico. Si bien el HTML renderizado por el servidor a menudo contiene estructuras de elementos repetidas, su contenido suele diferir; por ejemplo, leves variaciones en el texto o los atributos. Debido a que el contenido de una raíz paralela declarativa serializada es totalmente estático, actualizar varios elementos de una única raíz paralela declarativa solo funcionaría si los elementos fueran idénticos. Por último, el impacto de las raíces de sombra similares repetidas en el tamaño de transferencia de red es relativamente pequeño debido a los efectos de la compresión.

En el futuro, es posible que se puedan volver a revisar las raíces de sombra compartidas. Si el DOM es compatible con las plantillas integradas, las shadow root declarativas podrían tratarse como plantillas en las que se crean instancias para construir la shadow root de un elemento determinado. El diseño actual de Shadow DOM declarativo permite que esta posibilidad exista en el futuro limitando la asociación de shadow root a un solo elemento.

La transmisión es genial

Asociar las raíces de sombra declarativas directamente con su elemento superior simplifica el proceso de actualización y su vinculación a ese elemento. Los orígenes de sombra declarativos se detectan durante el análisis de HTML y se adjuntan de inmediato cuando se encuentra su etiqueta <template> de apertura. El HTML analizado dentro de <template> se analiza directamente en la raíz de sombra, por lo que se puede "transmitir": renderizar a medida que se recibe.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

Solo analizador

Shadow DOM declarativo es una función del analizador de HTML. Esto significa que una shadow root declarativa solo se analizará y adjuntará para las etiquetas <template> con un atributo shadowrootmode que estén presentes durante el análisis de HTML. En otras palabras, las raíces paralelas declarativas pueden construirse durante el análisis inicial del HTML:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

Si configuras el atributo shadowrootmode de un elemento <template>, no se hará nada, y la plantilla seguirá siendo un elemento de plantilla normal:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

Para evitar algunas consideraciones de seguridad importantes, tampoco se pueden crear raíces paralelas declarativas con APIs de análisis de fragmentos, como innerHTML o insertAdjacentHTML(). La única forma de analizar HTML con raíces de sombras declarativas aplicadas es usar setHTMLUnsafe() o parseHTMLUnsafe():

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

Renderización del servidor con estilo

Las hojas de estilo integradas y externas son totalmente compatibles dentro de las sombras paralelas declarativas con las etiquetas <style> y <link> estándar:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

Los estilos que se especifican de esta manera también están altamente optimizados: si la misma hoja de estilo está presente en varias Shadow Roots declarativas, solo se cargará y analizará una vez. El navegador usa un solo CSSStyleSheet de respaldo que comparten todas las raíces de sombra, lo que elimina la sobrecarga de memoria duplicada.

Los hojas de estilo componibles no son compatibles con el Shadow DOM declarativo. Esto se debe a que, en la actualidad, no hay forma de serializar hojas de estilo componibles en HTML ni de hacer referencia a ellas cuando se propaga adoptedStyleSheets.

Cómo evitar el parpadeo del contenido sin diseño

Un posible problema en los navegadores que aún no admiten Shadow DOM declarativo es evitar el "flash de contenido sin estilo" (FOUC), en el que se muestra el contenido sin procesar para los elementos personalizados que aún no se actualizaron. Antes de Shadow DOM declarativo, una técnica común para evitar la FOUC era aplicar una regla de estilo display:none a los elementos personalizados que aún no se habían cargado, ya que no se había conectado ni propagado su shadow root. De esta manera, el contenido no se muestra hasta que esté "listo":

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

Con la introducción de Shadow DOM declarativo, los elementos personalizados pueden representarse o crearse en HTML, de manera que su contenido shadow esté en su lugar y listo antes de que se cargue la implementación del componente del cliente:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

En este caso, la regla "FOUC" de display:none evitaría que se muestre el contenido de la shadow root declarativa. Sin embargo, si se quita esa regla, los navegadores que no admiten Shadow DOM declarativo mostrarían contenido incorrecto o sin estilo hasta que el polyfill del Shadow DOM declarativo se cargue y convierta la plantilla de shadow root en una shadow root real.

Afortunadamente, esto se puede resolver en CSS modificando la regla de estilo FOUC. En navegadores que admiten Shadow DOM declarativo, el elemento <template shadowrootmode> se convierte de inmediato en una shadow root, y no deja ningún elemento <template> en el árbol del DOM. Los navegadores que no admiten el DOM secundario declarativo conservan el elemento <template>, que podemos usar para evitar la FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

En lugar de ocultar el elemento personalizado aún no definido, la regla "FOUC" revisada oculta sus elementos secundarios cuando siguen un elemento <template shadowrootmode>. Una vez que se define el elemento personalizado, la regla ya no coincide. La regla se ignora en navegadores que admiten Shadow DOM declarativo porque se quita el elemento secundario <template shadowrootmode> durante el análisis de HTML.

Detección de funciones y compatibilidad con navegadores

El Shadow DOM declarativo está disponible desde Chrome 90 y Edge 91, pero usaba un atributo no estándar más antiguo llamado shadowroot en lugar del atributo shadowrootmode estandarizado. El atributo shadowrootmode y el comportamiento de transmisión más recientes están disponibles en Chrome 111 y Edge 111.

Como API de plataforma web nueva, Shadow DOM declarativo aún no es compatible con todos los navegadores. Se puede detectar la compatibilidad con el navegador si se verifica la existencia de una propiedad shadowRootMode en el prototipo de HTMLTemplateElement:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

Polyfill

La compilación de un polyfill simplificado para Shadow DOM declarativo es relativamente sencillo, ya que un polyfill no necesita replicar a la perfección la semántica de sincronización ni las características exclusivas del analizador que le preocupan a la implementación de un navegador. Para un Shadow DOM declarativo de polyfill, podemos escanear el DOM a fin de encontrar todos los elementos <template shadowrootmode> y, luego, convertirlos en shadow Roots adjuntos en su elemento superior. Este proceso se puede realizar una vez que el documento esté listo o se puede activar a través de eventos más específicos, como los ciclos de vida de los elementos personalizados.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

Lecturas adicionales