Shadow DOM v1: Componentes web independientes

Shadow DOM les permite a los programadores crear DOM y CSS compartimentados para componentes web

Resumen

Shadow DOM elimina la fragilidad en la creación de aplicaciones web. La fragilidad proviene de la naturaleza global de HTML, CSS y JS. Con el paso de los años, hemos inventado una exorbitante cantidad de herramientas para eludir los problemas. Por ejemplo, cuando usas un nuevo ID o una clase HTML, no se puede saber si estará en conflicto con un nombre existente que use la página. Aparecen errores sutiles, la especificidad de CSS se convierte en un gran problema (!important todo), los selectores de estilo se expanden sin control y el rendimiento puede verse afectado. La lista continúa.

Shadow DOM soluciona los problemas de CSS y DOM. Presenta estilos acotados en la plataforma web. Sin herramientas ni convenciones de nomenclatura, puedes combinar CSS con lenguaje de marcado, ocultar los detalles de implementación y crear componentes independientes en JavaScript clásico.

Introducción

Shadow DOM es uno de los tres estándares de componentes web: plantillas HTML, Shadow DOM y elementos personalizados. Las importaciones de HTML solían formar parte de la lista, pero ahora se consideran obsoletas.

No es necesario desarrollar componentes web que usen shadow DOM. Sin embargo, si lo haces, podrás aprovechar sus beneficios (alcance de CSS, encapsulación de DOM, composición, etc.) y desarrollar elementos personalizados reutilizables que sean resistentes, tengan una gran capacidad de configuración y se puedan usar una y otra vez. Si los elementos personalizados son la forma de crear un nuevo HTML (con una API de JS), shadow DOM es la forma de proporcionar su HTML y CSS. Las dos APIs se combinan para crear un componente con HTML, CSS y JavaScript independientes.

Shadow DOM está diseñado como una herramienta para crear apps basadas en componentes. Por lo tanto, tiene soluciones para problemas comunes del desarrollo web:

  • DOM aislado: El DOM de un componente es independiente (p. ej., document.querySelector() no mostrará nodos en el shadow DOM del componente).
  • CSS acotado: El CSS definido dentro de shadow DOM está acotado a él. Las reglas de estilo no filtran y los estilos de página no se infiltran.
  • Composición: Diseña una API declarativa y basada en lenguaje de marcado para tu componente.
  • Simplifica CSS: El DOM acotado implica que puedes usar selectores de CSS simples, nombres de ID o clase más genéricos, y no preocuparte por conflictos de nombres.
  • Productividad: Piensa en las apps como porciones de DOM en lugar de una página grande (global).

Demo de fancy-tabs

En este artículo, haré referencia a un componente de demostración (<fancy-tabs>) y a fragmentos de código de este. Si tu navegador admite las APIs, deberías ver una demostración en vivo a continuación. De lo contrario, consulta la fuente completa en GitHub.

Ver el código fuente en GitHub

¿Qué es shadow DOM?

Información general sobre el DOM

El lenguaje HTML alimenta toda la Web porque es fácil de usar. Declarando algunas etiquetas, puedes escribir en segundos una página que tenga presentación y estructura. Sin embargo, el HTML por sí solo no es muy útil. Es fácil para los humanos comprender lenguajes basados en texto, pero las máquinas necesitan algo más. Ingresa el Modelo de objetos del documento, o DOM.

Cuando el navegador carga una página web, realiza muchas acciones interesantes. Una de ellas es transformar el código HTML del autor en un documento activo. Básicamente, para interpretar la estructura de una página, el navegador analiza HTML (cadenas estáticas de texto) en un modelo de datos (objetos/nodos). El navegador conserva la jerarquía de HTML creando un árbol de estos nodos: el DOM. Lo genial del DOM es que es una representación activa de tu página. A diferencia del HTML estático que creamos, los nodos producidos por el navegador contienen propiedades, métodos y, lo mejor de todo, se pueden manipular con programas. Por esta razón, podemos crear elementos del DOM directamente con JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

produce el siguiente lenguaje de marcado HTML:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Todo eso está bien y es bueno. Entonces, ¿qué diablos es el shadow DOM?

DOM… en las sombras

Un shadow DOM es lo mismo que un DOM normal, pero con dos diferencias: 1) la manera en que se crea y se usa, y 2) la manera en que se comporta en relación con el resto de la página. Por lo general, se crean nodos DOM y se anexan como campos secundarios de otro elemento. Con shadow DOM, puedes crear un árbol del DOM con ámbito que se adjunte al elemento, pero separado de sus elementos secundarios reales. Este subárbol con alcance se denomina shadow tree (árbol en las sombras). El elemento al que se anexa se denomina host en las sombras. Cualquier cosa que agregues en las sombras se convierte en algo local del elemento de hosting, incluido <style>. Así es como shadow DOM logra acotar el alcance de estilo de CSS.

Cómo crear un shadow DOM

Un shadow root es un fragmento de documento que se adhiere a un elemento “host”. El acto de adjuntar una shadow root es la forma en que el elemento gana su shadow DOM. Para crear un shadow DOM para un elemento, llama a element.attachShadow():

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

Estoy usando .innerHTML para completar el shadow root, pero también puedes usar otras APIs de DOM. Esta es la Web. Tenemos opciones.

En las especificaciones, se define una lista de elementos que no pueden alojar un shadow tree. Existen varios motivos por los que un elemento puede estar en la lista:

  • El navegador ya aloja su propio shadow DOM interno para el elemento (<textarea>, <input>).
  • No tiene sentido que el elemento aloje un shadow DOM (<img>).

Por ejemplo, esto no funciona:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Cómo crear un shadow DOM para un elemento personalizado

Shadow DOM es particularmente útil cuando se crean elementos personalizados. Usa shadow DOM para compartimentar el HTML, CSS y JS de un elemento, y así crear un “componente web”.

Ejemplo: Un elemento personalizado se adhiere a shadow DOM y encapsula su DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Aquí hay algunos detalles interesantes. En primer lugar, el elemento personalizado crea su propio shadow DOM cuando se crea una instancia de <fancy-tabs>. Esto se hace en constructor(). En segundo lugar, como crearemos una shadow root, las reglas de CSS dentro de <style> se encontrarán dentro del ámbito de <fancy-tabs>.

Composición y ranuras

La composición es una de las funciones menos comprendidas de shadow DOM, pero es posiblemente la más importante.

En nuestro mundo de desarrollo web, la composición es cómo construimos apps, de forma declarativa, a partir de HTML. Se combinan diferentes bloques de compilación (<div>, <header>, <form> y <input>) para formar apps. Algunas de estas etiquetas incluso funcionan con las otras. La composición es el motivo por el que los elementos nativos como <select>, <details>, <form> y <video> son tan flexibles. Cada una de esas etiquetas acepta ciertos HTML como secundarios y hace algo especial con ellos. Por ejemplo, <select> sabe cómo renderizar <option> y <optgroup> en widgets desplegables y de selección múltiple. El elemento <details> renderiza <summary> como una flecha expandible. Incluso <video> sabe cómo lidiar con ciertos elementos secundarios: los elementos <source> no se representan, pero sí afectan el comportamiento del video. ¡Qué magia!

Terminología: DOM claro y shadow DOM

La composición de Shadow DOM introduce varios conceptos básicos nuevos en el desarrollo web. Antes de entrar en detalles, definamos algunos términos para asegurarnos de que manejar el mismo lenguaje.

DOM ligero

Es el lenguaje de marcado que escribe un usuario de tu componente. Este DOM reside fuera del shadow DOM del componente. Son los elementos secundarios reales del elemento.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

Es el DOM que escribe el autor del componente. Shadow DOM es local del componente y define su estructura interna, CSS con ámbito, y encapsula los detalles de tu implementación. También puede definir cómo representar el lenguaje de marcado que creó el consumidor de tu componente.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Árbol del DOM plano

Es el resultado que se obtiene después de que el navegador distribuye el light DOM del usuario en tu shadow DOM y que permite representar el producto final. El árbol plano es lo que en última instancia ves en las DevTools y lo que se representa en la página.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

El elemento <slot>

El shadow DOM compone distintos árboles DOM con el elemento <slot>. Los ranuras son marcadores de posición dentro de tu componente que los usuarios pueden completar con su propio lenguaje de marcado. Cuando defines uno o más slots, invitas al lenguaje de marcado externo a representarse en el shadow DOM de tu componente. Básicamente, estás diciendo "Renderiza el lenguaje de marcado del usuario aquí".

Los elementos pueden "cruzar" el límite del shadow DOM cuando un <slot> los invita. Estos elementos se denominan nodos distribuidos. Conceptualmente, los nodos distribuidos pueden parecer un poco extraños. Los slots no trasladan físicamente un DOM; lo representan en otra ubicación dentro del shadow DOM.

Un componente puede definir en su shadow DOM un número de slots de cero o adelante. Las ranuras pueden estar vacías o proporcionar contenido de resguardo. Si el usuario no proporciona contenido de light DOM, el slot representa su contenido de reserva.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

También puedes crear slots con nombre. Los espacios con nombre son agujeros específicos en tu shadow DOM a los que los usuarios hacen referencia por su nombre.

Ejemplo: Los slots en el shadow DOM de <fancy-tabs>:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Los usuarios de componentes declaran <fancy-tabs> de la siguiente manera:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

Y si te lo preguntas, el árbol plano luce así:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Ten en cuenta que nuestro componente puede controlar distintas configuraciones, pero el árbol DOM plano permanece igual. También podemos cambiar de <button> a <h2>. Este componente se creó para controlar diferentes tipos de elementos secundarios, al igual que <select>.

Diseño

Hay muchas opciones de diseño para los componentes web. Un componente que usa shadow DOM puede recibir su estilo de la página principal, definir un estilo propio o proporcionar enlaces (en forma de propiedades personalizadas de CSS) para que los usuarios cambien el estilo predeterminado.

Estilos definidos por el componente

Sin lugar a dudas, la función más útil de los shadow DOM es el CSS acotado:

  • Los selectores CSS de la página externa no se aplican dentro de tu componente.
  • Los estilos definidos dentro del componente no influyen en el exterior. Tienen un ámbito fijado en el elemento host.

Los selectores CSS que se usan dentro del shadow DOM se aplican a tu componente de forma local. En la práctica, esto significa que podemos volver a usar nombres de ID y clases comunes sin preocuparnos por conflictos en otro sector de la página. Los selectores de CSS más simples son una práctica recomendada dentro de Shadow DOM. También contribuyen al rendimiento.

Ejemplo: los estilos definidos en una shadow root son locales.

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Las hojas de estilo también tienen un ámbito fijado en el shadow tree:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

¿Alguna vez te preguntaste cómo el elemento <select> renderiza un widget de selección múltiple (en lugar de un menú desplegable) cuando agregas el atributo multiple:

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> puede definir su propio estilo de distintas formas según los atributos que declares. Los componentes web también pueden definir su propio estilo si usas el selector :host.

Ejemplo: Un componente que define su propio estilo.

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Un problema con :host es que las reglas de la página superior tienen una especificidad mayor que las reglas de :host definidas en el elemento. Esto significa que los estilos exteriores tienen prioridad. De esta manera, los usuarios pueden anular tu diseño de nivel superior desde el exterior. Además, :host solo funciona en el contexto de una shadow root, por lo cual no puedes usarlo fuera de shadow DOM.

El formato funcional de :host(<selector>) te permite orientar al host si coincide con una <selector>. Esta es una excelente manera de que tu componente encapsule comportamientos que reaccionan a la interacción del usuario o al estado o al diseño de nodos internos basados en el host.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Estilos basados en el contexto

:host-context(<selector>) coincide con el componente si él o cualquiera de sus ancestros coinciden con <selector>. Un uso común de esto es la app de temas según el entorno de un componente. Por ejemplo, muchas personas aplican temas aplicando una clase a <html> o <body>:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) le daría estilo a <fancy-tabs> cuando es subordinado de .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() puede ser útil para aplicar temas, pero un enfoque aún más adecuado sería crear enlaces de estilo con propiedades personalizadas de CSS.

Aplica diseño a los nodos distribuidos

::slotted(<compound-selector>) coincide con los nodos que se distribuyen en un <slot>.

Supongamos que hemos creado un componente de identificación:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

El shadow DOM del componente puede definir el estilo de <h2> y .title del usuario:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Como vimos antes, los objetos <slot> no mueven el Light DOM del usuario. Cuando los nodos se distribuyen en un <slot>, el <slot> representa su DOM, pero los nodos no se mueven. Los estilos que se aplicaron antes de la distribución se siguen aplicando después de esta. Sin embargo, cuando se distribuye, el light DOM puede incorporar estilos adicionales (definidos por el shadow DOM).

A continuación, se muestra otro ejemplo más detallado de <fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

En este ejemplo, hay dos ranuras: una con nombre para los títulos de las pestañas y una para el contenido del panel de pestañas. Cuando el usuario selecciona una pestaña, destacamos su selección en negrita y revelamos el panel. Para lograr esto, se seleccionan nodos distribuidos que tienen el atributo selected. El JS del elemento personalizado (que no se muestra aquí) agrega ese atributo en el momento correcto.

Cómo aplicar diseño a un componente desde el exterior

Hay varias formas de definir el estilo de un componente desde el exterior. La forma más simple es usar el nombre de la etiqueta como selector:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Los estilos externos siempre prevalecen sobre los estilos definidos en el shadow DOM. Por ejemplo, si el usuario escribe el selector fancy-tabs { width: 500px; }, este anulará la regla del componente: :host { width: 650px;}.

No será suficiente con definir el estilo propio del componente. Pero, ¿qué sucede si quieres definir el estilo del interior de un componente? Para eso, necesitamos propiedades personalizadas de CSS.

Cómo crear hooks de estilo con propiedades personalizadas de CSS

Los usuarios pueden retocar los estilos internos si el autor del componente proporciona enlaces de estilo con propiedades personalizadas de CSS. Conceptualmente, la idea es similar a <slot>. Se crean “marcadores de posición de estilo” que los usuarios pueden anular.

Ejemplo: <fancy-tabs> permite a los usuarios anular el color de fondo:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Dentro de su shadow DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

En este caso, el componente usará black como el valor de fondo porque el usuario lo proporcionó. De lo contrario, usaría el valor predeterminado #9E9E9E.

Temas avanzados

Cómo crear shadow root cerradas (debe evitarse)

Existe otra clase de shadow DOM llamada modo “cerrado”. Cuando creas un árbol de sombras cerrado, el JavaScript exterior no puede acceder al DOM interno de tu componente. Esto es similar a la forma en que funcionan los elementos nativos, como <video>. JavaScript no puede acceder al shadow DOM de <video> porque el navegador lo implementa usando una shadow root de modo cerrado.

Ejemplo: cómo crear un shadow tree cerrado:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

Otras APIs también se ven afectadas por el modo cerrado:

  • Element.assignedSlot / TextNode.assignedSlot muestra null
  • Event.composedPath() para eventos asociados con elementos dentro del shadow DOM, muestra [].

A continuación, se resumen los motivos por los cuales nunca deberías crear componentes web con {mode: 'closed'}:

  1. Sensación de seguridad artificial. No hay nada que evite que un atacante se haga cargo de Element.prototype.attachShadow.

  2. El modo cerrado evita que el código de tu elemento personalizado acceda a su propio shadow DOM. Un error total. En cambio, tendrás que guardar una referencia para más adelante si quieres usar elementos como querySelector(). ¡Esto elimina por completo el objetivo original del modo cerrado!

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. El modo cerrado hace que el componente sea menos flexible para los usuarios finales. A medida que compiles componentes web, llegará un momento en que te olvidarás de agregar una función. Una opción de configuración. Un caso de uso que el usuario necesite. Un ejemplo común es olvidarse de incluir enlaces de estilo adecuados para nodos internos. En el modo cerrado, los usuarios no pueden anular los valores predeterminados ni retocar los estilos. Poder acceder al interior del componente es muy útil. A la larga, los usuarios separarán tu componente, buscarán otro o crearán uno propio si no funciona como desean :(.

Trabaja con slots en JS

La API de shadow DOM proporciona utilidades para trabajar con slots y nodos distribuidos. Son útiles cuando desarrollas un elemento personalizado.

evento slotchange

El evento slotchange se activa cuando cambian los nodos distribuidos de un slot. Por ejemplo, si el usuario agrega campos secundarios al light DOM o los quita de este.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

Para supervisar otros tipos de cambios en un light DOM, puedes configurar un MutationObserver en el constructor de tu elemento.

¿Qué elementos se renderizan en un slot?

A veces, resulta útil conocer los elementos asociados a un slot. Llama a slot.assignedNodes() para encontrar qué elementos representa el slot. La opción {flatten: true} también mostrará el contenido de reserva de un slot (si no se distribuyen nodos).

Por ejemplo, supongamos que tu DOM en sombra tiene el siguiente aspecto:

<slot><b>fallback content</b></slot>
UsoLlamarResultado
<my-component>texto del componente</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

¿A qué ranura se asigna un elemento?

También es posible responder la pregunta inversa. element.assignedSlot te indica a cuál de los slots de componentes se asigna tu elemento.

El modelo de eventos de Shadow DOM

Cuando un evento surge del shadow DOM, se ajusta su objetivo para mantener la encapsulación que brinda el shadow DOM. Es decir, se modifica el objetivo de los eventos para que parezca que provienen del componente y no de elementos internos de tu shadow DOM. Algunos eventos ni siquiera se propagan fuera del shadow DOM.

Los eventos que cruzan la frontera de la “sombra” son los siguientes:

  • Eventos de enfoque: blur, focus, focusin y focusout
  • Eventos del mouse: click, dblclick, mousedown, mouseenter, mousemove, etcétera
  • Eventos de la rueda: wheel
  • Eventos de entrada: beforeinput y input
  • Eventos de teclado: keydown, keyup
  • Eventos de composición: compositionstart, compositionupdate y compositionend
  • DragEvent: dragstart, drag, dragend, drop, etcétera

Sugerencias

Si el shadow tree está abierto, llamar a event.composedPath() mostrará un array de nodos por los que recorrió el evento.

Utiliza eventos personalizados

Los eventos del DOM personalizados que se emiten en nodos internos de un shadow tree no se propagan fuera del límite de shadow, a menos que el evento se cree con el indicador composed: true:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Si composed: false (predeterminado), los consumidores no podrán escuchar el evento fuera de tu shadow root.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Cómo manejar el foco

Si recuerdas del modelo de eventos del shadow DOM, los eventos que se disparan dentro del shadow DOM se ajustan para que parezca que provienen del elemento de hosting. Por ejemplo, supongamos que haces clic en una <input> dentro de una shadow root:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

El evento focus parecerá provenir de <x-focus>, no de <input>. Del mismo modo, document.activeElement será <x-focus>. Si la raíz de sombra se creó con mode:'open' (consulta el modo cerrado), también podrás acceder al nodo interno que ganó foco:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Si hay varios niveles de shadow DOM en juego (como un elemento personalizado dentro de otro elemento personalizado), tienes que explorar las shadow roots de forma recursiva para encontrar el activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Otra opción de enfoque es la opción delegatesFocus: true, que expande el comportamiento del enfoque de los elementos dentro de un árbol de sombras:

  • Si haces clic en un nodo dentro de shadow DOM y el nodo no es un área enfocable, la primera área enfocable se enfoca.
  • Cuando un nodo dentro del shadow DOM recibe el foco, :focus se aplica al host además del elemento con foco.

Ejemplo: Cómo delegatesFocus: true cambia el comportamiento del enfoque

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Resultado

delegatesFocus: comportamiento verdadero.

Arriba está el resultado cuando <x-focus> recibe el foco (clic del usuario, en pestaña, focus(), etc.). Se hace clic en "texto del Shadow DOM al que se le hace clic" o se enfoca el <input> interno (incluida la autofocus).

Si configuraras delegatesFocus: false, esto es lo que verías en su lugar:

delegatesFocus: falso y la entrada interna se enfoca.
delegatesFocus: false y el <input> interno están enfocados.
delegatesFocus: false y x-focus obtiene el foco (p. ej., tiene tabindex=&#39;0&#39;).
delegatesFocus: false y <x-focus> obtienen el foco (p.ej., tiene tabindex="0").
delegatesFocus: false y se hace clic en &quot;texto del Shadow DOM al que se le hace clic&quot; (o se hace clic en otra área vacía dentro del shadow DOM del elemento).
delegatesFocus: false y se hace clic en "Texto del Shadow DOM en el que se puede hacer clic" (o se hace clic en otra área vacía dentro del shadow DOM del elemento).

Sugerencias

Con el paso de los años, he aprendido algunas cosas sobre la creación de componentes web. Considero que algunas de estas sugerencias te resultarán útiles para crear componentes y depurar shadow DOM.

Usar la contención de CSS

Por lo general, el diseño, el estilo y la pintura de un componente web son bastante independientes. Usa la contención de CSS en :host para obtener un aumento del rendimiento:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Restablecimiento de estilos heredables

Los estilos heredables (background, color, font, line-height, etc.) continúan heredando en shadow DOM. Es decir, perforan el límite del shadow DOM de forma predeterminada. Si quieres empezar de cero, usa all: initial; para restablecer los estilos heredables a su valor inicial cuando crucen el límite de la sombra.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Cómo encontrar todos los elementos personalizados que usa una página

A veces, resulta útil buscar los elementos personalizados que usa una página. Para hacerlo, debes atravesar de manera recursiva el shadow DOM de todos los elementos usados en la página.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Cómo crear elementos a partir de una <template>

En lugar de mostrar una shadow root usando .innerHTML, podemos usar un <template> declarativo. Las plantillas son marcadores de posición ideales para declarar la estructura de un componente web.

Consulta el ejemplo en “Elementos personalizados: compilación de componentes web reutilizables”.

Historial y compatibilidad con navegadores

Si has estado siguiendo a los componentes web durante los últimos años, sabrás que Chrome 35+/Opera han estado enviando una versión más vieja de shadow DOM durante un buen tiempo. Blink seguirá admitiendo ambas versiones en paralelo durante un tiempo. Las especificaciones de v0 proporcionan un método diferente para crear una shadow root (element.createShadowRoot en lugar de element.attachShadow de v1). Si llamas al método anterior, la shadow root se creará con semántica de v0, por lo que el código de v0 existente no se romperá.

Si estás interesado en la especificación anterior de v0, consulta los artículos de html5rocks: 1, 2 y 3. También hay una gran comparación de las diferencias entre shadow DOM v0 y v1.

Navegadores compatibles

Shadow DOM v1 se envía en Chrome 53 (estado), Opera 40, Safari 10 y Firefox 63. Edge comenzó el desarrollo.

Para detectar shadow DOM por medio de funciones, verifica si existe attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Hasta que la compatibilidad con navegadores esté ampliamente disponible, los polyfills shadydom y shadycss te brindan la función v1. Shady DOM imita el alcance del DOM de Shadow DOM y las propiedades personalizadas de CSS de los polyfills shadycss, así como el alcance del estilo que proporciona la API nativa.

Instala los polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Usa los polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

Consulta https://github.com/webcomponents/shadycss#usage para obtener instrucciones sobre cómo corregir la compatibilidad de/limitar tus estilos.

Conclusión

Por primera vez, tenemos una primitiva de API que limita CSS y DOM correctamente, y tiene una verdadera composición. Combinado con otras APIs de componentes web, como elementos personalizados, shadow DOM ofrece una forma de crear componentes verdaderamente encapsulados sin modificaciones ni uso de bagaje como <iframe>.

No me malinterpretes. Shadow DOM es, sin duda, una bestia compleja. Pero es un monstruo que vale la pena aprender. Dedícale tiempo. ¡Aprende a usarlo y haz preguntas!

Lecturas adicionales

Preguntas frecuentes

¿Puedo usar Shadow DOM v1 en la actualidad?

Sí, con un polyfill. Consulta Compatibilidad con navegadores.

¿Qué funciones de seguridad proporciona shadow DOM?

Shadow DOM no es una función de seguridad. Es una herramienta liviana para acotar CSS y ocultar árboles DOM en componentes. Si quieres un verdadero límite de seguridad, usa un <iframe>.

¿Debe un componente web usar shadow DOM?

De ninguna manera. No es necesario desarrollar componentes web que usen shadow DOM. Sin embargo, si desarrollas elementos personalizados que usan Shadow DOM, podrás aprovechar funciones como la fijación de ámbitos de CSS, la encapsulación de DOM y la composición.

¿Cuál es la diferencia entre las shadow root abiertas y cerradas?

Consulta Raíces de sombras cerradas.