Shadow DOM declarativo

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

Shadow DOM declarativo es una función de plataforma web que actualmente se encuentra en el proceso de estandarización. Está habilitado de forma predeterminada en la versión 111 de Chrome.

Shadow DOM es uno de los tres estándares de componentes web y se completa con plantillas 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 Shadow Tree. La combinación de estas funciones permite que un sistema cree componentes independientes y reutilizables que se integran perfectamente en las 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 servidor o de HTML estático durante el tiempo de compilación. Esto puede ser una parte importante para brindar una experiencia razonable a los visitantes que quizás no sean capaces de 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 en su totalidad para cumplir con los lineamientos de accesibilidad, mientras que otros optan por ofrecer una experiencia sin JavaScript de referencia 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 consecuencias en el rendimiento cuando se adjuntan Shadow Roots a los elementos del DOM que ya se renderizaron sin ellos. Esto puede provocar que se cambie el diseño después de que se cargue la página o que se muestre temporalmente contenido sin estilo ("FOUC") mientras se cargan las hojas de estilo de Shadow Root.

Shadow DOM declarativo (DSD) quita esta limitación y lleva el 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 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>

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

Esto nos da los beneficios de la encapsulación de Shadow DOM y la proyección de ranuras en HTML estático. No se necesita JavaScript para producir todo el árbol, incluido Shadow Root.

Hidratación de componentes

El Shadow DOM declarativo se puede usar por sí solo como una forma de encapsular estilos o personalizar la posición secundaria, 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 del Shadow DOM declarativo, ahora es posible que un elemento personalizado tenga una shadow root antes de actualizarse.

Los elementos personalizados que se actualicen desde HTML y que incluyan una Shadow Root declarativa ya tendrán esa shadow root adjunta. Esto significa que el elemento ya tendrá una propiedad shadowRoot disponible cuando se cree una instancia de este, sin que tu código cree una de forma explícita. Es mejor verificar this.shadowRoot en busca de alguna shadow root existente en el constructor de tu elemento. Si ya hay un valor, el HTML de este componente incluye una Shadow Root declarativa. Si el valor es nulo, significa que no hay ninguna Shadow Root declarativa presente en el 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 un tiempo y, hasta ahora, no había motivo para verificar 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 lo anterior: llamar al método attachShadow() en un elemento con una Shadow Root Declarative existente no generará un error. En cambio, la Shadow Root declarativa se vacía y se muestra. Esto permite que los componentes más antiguos que no se compilaron para 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 propiedad ElementInternals.shadowRoot nueva proporciona una forma explícita de obtener una referencia a la Shadow Root declarativa existente de un elemento, tanto abierta como cerrada. Se puede usar para buscar y usar cualquier Shadow Root declarativa, y recurrir a attachShadow() en los casos en que no se proporcionó uno.

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 Shadow Root 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 la generación, ya que agregar una shadow root a un elemento no requiere mantener un registro de las shadow root existentes.

La desventaja de asociar shadow roots con su elemento superior es que no es posible inicializar varios elementos desde el mismo <template> de shadow root declarativa. Sin embargo, es poco probable que esto importe 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 código HTML procesado por el servidor a menudo contiene estructuras de elementos repetidas, su contenido suele diferir; por ejemplo, ligeras variaciones en el texto o los atributos. Debido a que el contenido de una Shadow Root declarativa serializada es completamente estático, actualizar varios elementos de una sola Shadow Root declarativa solo funcionaría si los elementos fueran idénticos. Por último, el impacto de raíces paralelas 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 revisar las shadow roots compartidas. Si el DOM obtiene compatibilidad con las plantillas integradas, las shadow root declarativas se podrían tratar como plantillas en las que se crean instancias para construir la shadow root para un elemento determinado. El diseño declarativo de Shadow DOM actual permite que esta posibilidad exista en el futuro, ya que limita la asociación de shadow root a un solo elemento.

Transmitir es genial

Asociar Shadow Roots declarativas directamente con su elemento superior simplifica el proceso de actualización y adjuntarlas a ese elemento. Las Shadow Roots declarativas se detectan durante el análisis de HTML y se adjuntan de inmediato cuando se encuentra su etiqueta de apertura <template>. El código HTML analizado dentro de <template> se analiza directamente en shadow root, de modo que se pueda "transmitir": se renderiza 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 solo se analizará y adjuntará una Shadow Root declarativa para las etiquetas <template> con un atributo shadowrootmode que estén presentes durante el análisis de HTML. En otras palabras, las shadow root declarativas se pueden construir durante el análisis inicial de HTML:

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

Configurar el atributo shadowrootmode de un elemento <template> no realiza ninguna acción, 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 shadow root declarativas usando APIs de análisis de fragmentos, como innerHTML o insertAdjacentHTML(). La única forma de analizar HTML con Shadow Roots declarativos aplicados es pasar una nueva opción includeShadowRoots a DOMParser:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root here
</script>

Renderización del servidor con estilo

Las hojas de estilo intercaladas y externas son totalmente compatibles con Shadow Roots declarativas mediante 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 especificados 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 utiliza un único CSSStyleSheet de copia de seguridad que comparten todas las shadow roots y que elimina la sobrecarga de memoria duplicada.

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

Evita el destello de contenido sin estilo

Un problema potencial en los navegadores que aún no admiten el Shadow DOM declarativo es evitar el "destellos 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 el FOUC era aplicar una regla de estilo display:none a los elementos personalizados que aún no se cargaron, ya que no se había propagado ni agregado su shadow root. De esta manera, el contenido no se mostrará 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 paralelo esté local 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 sin compatibilidad con Shadow DOM declarativo muestren contenido incorrecto o sin estilo hasta que el polyfill declarativo del Shadow DOM 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 los navegadores que admiten Shadow DOM declarativo, el elemento <template shadowrootmode> se convierte de inmediato en una shadow root, lo que 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 “FOUC” revisada 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 el elemento secundario <template shadowrootmode> se quita 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 utilizaba un atributo no estándar más antiguo 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 nueva API de plataforma web, el Shadow DOM declarativo aún no tiene una compatibilidad generalizada con todos los navegadores. La compatibilidad con el navegador se puede detectar verificando la existencia de una propiedad shadowRootMode en el prototipo de HTMLTemplateElement:

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

Polyfill

Compilar un polyfill simplificado para un Shadow DOM declarativo es un proceso relativamente sencillo, ya que no es necesario que replique de forma perfecta la semántica de tiempo ni las características exclusivas del analizador a las que se ocupa una implementación de navegador. Para generar polyfills declarativos de Shadow DOM, podemos analizar el DOM para encontrar todos los elementos <template shadowrootmode> y, luego, convertirlos en Shadow Roots adjuntas en su elemento superior. Este proceso se puede realizar una vez que el documento esté listo o se puede activar mediante 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