Shadow DOM declarativo

Una nueva forma de implementar y usar Shadow DOM directamente en HTML.

Shadow DOM declarativo es una función estándar de la plataforma web, compatible con 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.

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 para un subárbol de DOM específico y aislar ese subárbol del resto del documento. El elemento <slot> nos permite controlar dónde se deben insertar los elementos secundarios de un elemento personalizado dentro de su árbol sombra. Estas funciones combinadas permiten un sistema para compilar componentes independientes reutilizables que se integran sin inconvenientes en aplicaciones existentes, al igual que un elemento HTML integrado.

Hasta ahora, la única manera de usar Shadow DOM era construir una shadow root usando 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 procesar contenido del lado del servidor o HTML estático en el tiempo de compilación. Esto puede ser importante para brindar una experiencia razonable a los visitantes que tal vez no puedan ejecutar JavaScript.

Las justificaciones para la renderización del servidor (SSR) varían de un proyecto a otro. Algunos sitios web deben proporcionar un código HTML completamente funcional procesado por el servidor para cumplir con los lineamientos de accesibilidad, mientras que 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 shadow Roots en el HTML generado por el servidor. También hay implicaciones de rendimiento cuando se adjuntan raíces paralelas a elementos del DOM que ya se renderizaron sin ellas. Esto puede hacer que el diseño cambie después de que se cargue la página o mostrar temporalmente un destello de contenido sin estilo ("FOUC") mientras se cargan las hojas de estilo de la raíz secundaria.

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

Cómo crear una raíz paralela declarativa

Una shadow root 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 se aplica de inmediato como la raíz secundaria de su elemento superior. Cuando se carga el lenguaje de marcado HTML puro del ejemplo anterior, se 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 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 el árbol completo, incluida la raíz paralela.

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 elementos secundarios, pero es más eficaz cuando se usa con elementos personalizados. Los componentes creados con elementos personalizados se actualizan automáticamente desde el código HTML estático. Con la introducción de Shadow DOM declarativo, ahora es posible que un elemento personalizado tenga una shadow root antes de actualizarse.

Un elemento personalizado que se actualiza desde HTML y que incluye una shadow root declarativa ya tendrá esa shadow root conectada. Esto significa que el elemento ya tendrá una propiedad shadowRoot cuando se crea una instancia, sin que tu código la cree explícitamente. Es mejor verificar this.shadowRoot en busca de cualquier shadow root existente en el constructor de tu elemento. Si ya existe un valor, el código HTML de este componente incluye una raíz paralela declarativa. Si el valor es nulo, significa que no había Shadow root declarativa presente en el código HTML o el navegador no admite 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 hace un tiempo y, hasta ahora, no había motivo para buscar una shadow root existente antes de crear una con attachShadow(). El Shadow DOM declarativo incluye un pequeño cambio que permite que los componentes existentes funcionen a pesar de esto: 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. De esta manera, los componentes más antiguos que no están diseñados 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 a la raíz paralela declarativa existente de un elemento, tanto abierta como cerrada. Se puede usar para verificar y usar cualquier raíz paralela 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

Una raíz paralela declarativa 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 las shadow roots con su elemento superior es que no es posible inicializar varios elementos a partir de la misma shadow root declarativa <template>. 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 suele contener estructuras de elementos repetidas, su contenido suele diferir, por ejemplo, variaciones leves 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 shadow root similares repetidas en el tamaño de la transferencia de red es relativamente pequeño debido a los efectos de la compresión.

En el futuro, tal vez sea posible volver a visitar las shadow root 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.

Las transmisiones son geniales

Asociar las raíces paralelas declarativas directamente con su elemento superior simplifica el proceso de actualización y de adjuntarlas a ese elemento. Las shadow Roots declarativas se detectan durante el análisis de HTML y se adjuntan de inmediato cuando se encuentra la etiqueta de apertura <template>. El HTML analizado dentro de <template> se analiza directamente en la shadow root, de modo que se pueda "transmitir" y se procese 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, se pueden construir raíces paralelas declarativas durante el análisis inicial del HTML:

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

La configuración del atributo shadowrootmode de un elemento <template> no tiene ningún efecto, y la plantilla sigue siendo un elemento común:

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 las raíces paralelas declarativas 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 diseños 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 único CSSStyleSheet de copia de seguridad compartido por todas las shadow roots, lo que elimina la sobrecarga de memoria duplicada.

Las hojas de estilo constructibles no se admiten en el Shadow DOM declarativo. Esto se debe a que, actualmente, no hay forma de serializar hojas de estilo constructibles en HTML, ni de hacer referencia a ellas cuando se propaga adoptedStyleSheets.

Evita el destello de contenido sin estilo

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 del Shadow DOM declarativo, una técnica común para evitar FOUC era aplicar una regla de estilo display:none a los elementos personalizados que aún no se habían cargado, ya que no tenían sus shadow root adjuntas ni propagadas. 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 se pueden renderizar o crear en HTML, de modo 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, quitar esa regla provocaría que los navegadores no compatibles con Shadow DOM declarativo muestren contenido incorrecto o sin estilo hasta que el polyfill del Shadow DOM declarativo se cargue y convierta la plantilla de shadow root real.

Por suerte, 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 Shadow DOM declarativo conservan el elemento <template>, que podemos usar para evitar el FOUC:

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

En lugar de ocultar el elemento personalizado aún no definido, la regla revisada "FOUC" oculta sus elementos secundarios cuando siguen un elemento <template shadowrootmode>. Una vez que se define el elemento personalizado, la regla deja de coincidir. 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 este usaba un atributo no estándar anterior 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, el Shadow DOM declarativo aún no es compatible con todos los navegadores. La compatibilidad con el navegador se puede detectar 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 el 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 analizar el DOM a fin de encontrar todos los elementos <template shadowrootmode> y, luego, convertirlos en Shadow DOM adjuntas en el 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