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 el cambio de nombre de shadowroot a shadowrootmode) y que 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 Web Components, junto con las plantillas HTML y los 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 raíces de sombra y establecen 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 renderizado por el servidor y completamente funcional para cumplir con los lineamientos de accesibilidad. Otros prefieren ofrecer una experiencia de referencia sin JavaScript como una forma de garantizar un buen rendimiento en conexiones o dispositivos lentos.

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 flash 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 un elemento raíz de sombra declarativo

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 Chrome DevTools para mostrar contenido de 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 slots 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 actualice desde HTML y que incluya una raíz de sombra declarativa ya tendrá esa raíz de sombra adjunta. Esto significa que el elemento tendrá una propiedad shadowRoot ya disponible cuando se cree una instancia, sin que tu código cree una 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(). 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 elemento raíz de sombra declarativo existente no arrojará un error. En su lugar, se vacía y se muestra la raíz de sombra declarativa. Esto permite que los componentes más antiguos que no se compilaron para el Shadow DOM declarativo sigan 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 raiz de sombra declarativa existente de un elemento, tanto abierto como cerrado. Se puede usar para buscar y usar cualquier raíz de sombra declarativa, sin dejar de recurrir a attachShadow() en los casos en 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 raíces de sombra siempre se encuentran en la misma ubicación que su elemento asociado. Esta decisión de diseño garantiza que las raíces de sombra se puedan transmitir como el resto de un documento HTML. También es conveniente para la autoría y la generación, ya que agregar una raíz de sombra a un elemento no requiere mantener un registro de las raíces de sombra 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 importe en la mayoría de los casos en los que se usa el DOM secundario declarativo, ya que el contenido de cada raíz de sombra 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, con ligeras variaciones en el texto o los atributos. Debido a que el contenido de una raíz de sombra declarativa serializada es completamente estático, la actualización de varios elementos desde una sola raíz de sombra 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 obtiene compatibilidad con las plantillas integradas, las raíces de sombra declarativas se podrían tratar como plantillas que se crean instancias para construir la raíz de sombra de un elemento determinado. El diseño actual de Shadow DOM declarativo permite que esta posibilidad exista en el futuro, ya que limita la asociación de la raíz de sombra 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

El Shadow DOM declarativo es una función del analizador HTML. Esto significa que un elemento raíz de sombra declarativo 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, los cimientos de sombra declarativos se pueden construir durante el análisis HTML inicial:

<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, los orígenes de sombra declarativos tampoco se pueden crear 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 intercaladas y externas son totalmente compatibles con las raíces de sombra 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 especificados de esta manera también están muy optimizados: si la misma hoja de estilo está presente en varios elementos raíz de sombra declarativos, solo se carga y analiza 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 problema potencial en los navegadores que aún no admiten el Shadow DOM declarativo es evitar el "destello de contenido sin diseño" (FOUC), en el que se muestra el contenido sin procesar de 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 del Shadow DOM declarativo, los elementos personalizados se pueden renderizar o crear en HTML de modo que su contenido en sombra 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 del raiz de sombra declarativo. Sin embargo, quitar esa regla haría que los navegadores sin compatibilidad con el Shadow DOM declarativo mostraran contenido incorrecto o sin diseño hasta que se cargue el polyfill de Shadow DOM declarativo y convierta la plantilla de raíz de sombra en una raíz de sombra real.

Afortunadamente, esto se puede resolver en CSS si se modifica la regla de estilo de FOUC. En los navegadores que admiten Shadow DOM declarativo, el elemento <template shadowrootmode> se convierte de inmediato en una raíz de sombra, sin dejar 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 los 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 una nueva API de la plataforma web, el Shadow DOM declarativo aún no tiene compatibilidad generalizada en todos los navegadores. Para detectar la compatibilidad con el navegador, 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 sencilla, ya que no es necesario que un polyfill replique perfectamente la semántica de los tiempos ni las características solo de analizador que le interesan a una implementación de navegador. Para polyfillar el DOM secundario declarativo, podemos analizar el DOM para encontrar todos los elementos <template shadowrootmode> y, luego, convertirlos en raíces de sombra adjuntas en su elemento superior. Este proceso se puede realizar una vez que el documento esté listo o se active con 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