Shadow DOM declarativo

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.

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 manera de delimitar los estilos de CSS en un subárbol específico del DOM 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 sombrío. Estas funciones combinadas permiten un sistema para crear componentes independientes reutilizables que se integran sin inconvenientes a 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 en HTML estático durante 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 DOM en el HTML generado por el servidor. También hay consecuencias en el rendimiento al adjuntar Shadow Roots a elementos del DOM que ya se procesaron sin ellos. 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 Shadow Root.

Shadow DOM declarativo (DSD) quita esta limitación, lo que lleva a Shadow DOM al servidor.

Cómo compilar una shadow Root 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 proporciona 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 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 shadow root existente en el constructor del elemento. Si ya existe un valor, el HTML de este componente incluye una raíz paralela declarativa. Si el valor es nulo, significa que no había Shadow Root declarativo 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 desde hace tiempo y, hasta ahora, no había motivo para buscar una shadow root existente antes de crear una 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 a la raíz paralela declarativa existente de un elemento, tanto abierta como cerrada. 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

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 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 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 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

La asociación directa de las raíces paralelas declarativas con el 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, 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>

Configurar el 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 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 único CSSStyleSheet de copia de seguridad que comparten todas las shadow root, 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, en la actualidad, no hay forma de serializar hojas de estilo constructibles en HTML, ni de hacer referencia a ellas cuando se propaga adoptedStyleSheets.

Cómo evitar el destello de contenido sin estilo

Un posible problema en los navegadores que todavía no admiten Shadow DOM declarativo es evitar el "destello de contenido sin estilo" (FOUC), en el que el contenido sin procesar se muestra 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 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, el display:none “FOUC” 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.

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, el "FOUC" revisado oculta sus elementos secundarios cuando siguen a 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

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 estandarizado shadowrootmode. 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 comprobando 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