Shadow DOM v1: Componentes web independientes

Shadow DOM permite a los desarrolladores web crear DOM y CSS compartimentados para los componentes web.

Resumen

Shadow DOM quita la fragilidad de la compilación de apps web. La fragilidad proviene de la naturaleza global de HTML, CSS y JS. A lo largo de los años, inventamos una cantidad exorbitante de herramientas para evitar los problemas. Por ejemplo, cuando usas un nuevo ID o clase HTML, no se puede saber si entrará en conflicto con un nombre existente que usa la página. Aparecen errores sutiles, la especificidad de CSS se convierte en un gran problema (!important todo lo que quieras), los selectores de estilo se salen de control y el rendimiento puede verse afectado. La lista sigue.

Shadow DOM corrige CSS y DOM. Presenta los estilos centrados en la plataforma web. Sin herramientas ni convenciones de nombres, puedes combinar CSS con marcado, ocultar detalles de implementación y escribir componentes independientes en JavaScript sin modificaciones.

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 ser parte de la lista, pero ahora se consideran obsoletas.

No tienes que crear componentes web que usen el DOM secundario. Sin embargo, cuando lo haces, aprovechas sus beneficios (alcance de CSS, encapsulamiento de DOM, composición) y compilas elementos personalizados reutilizables, que son resistentes, altamente configurables y extremadamente reutilizables. Si los elementos personalizados son la forma de crear un HTML nuevo (con una API de JS), Shadow DOM es la forma en que proporcionas 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 compilar apps basadas en componentes. Por lo tanto, proporciona soluciones para problemas comunes en el desarrollo web:

  • DOM aislado: El DOM de un componente es autónomo (p.ej., document.querySelector() no mostrará nodos en el Shadow DOM del componente).
  • CSS con alcance: El CSS definido dentro de Shadow DOM tiene un alcance definido. Las reglas de diseño no se filtran y los diseños de página no se extienden.
  • Composición: Diseña una API declarativa basada en marcado para tu componente.
  • Simplifica el CSS: El DOM con alcance significa que puedes usar selectores CSS simples, nombres de ID o clase más genéricos y no preocuparte por los conflictos de nombres.
  • Productividad: Piensa en las apps como fragmentos de DOM en lugar de una página grande (global).

Demo de fancy-tabs

A lo largo de 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 justo debajo. 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 HTML potencia la Web porque es fácil trabajar con él. Si declaras algunas etiquetas, puedes crear una página en segundos que tenga presentación y estructura. Sin embargo, el HTML por sí solo no es tan útil. Es fácil para las personas comprender un lenguaje basado 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 las tareas que realiza es transformar el código HTML del autor en un documento en vivo. Básicamente, para comprender la estructura de la página, el navegador analiza el código HTML (cadenas estáticas de texto) en un modelo de datos (objetos o nodos). El navegador conserva la jerarquía del HTML creando un árbol de estos nodos: el DOM. Lo bueno del DOM es que es una representación en vivo de tu página. A diferencia del HTML estático que escribimos, los nodos producidos por el navegador contienen propiedades, métodos y, lo mejor de todo, los programas pueden manipularlos. Es por eso que 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. Entonces, ¿qué es el shadow DOM?

DOM… en las sombras

Shadow DOM es un DOM normal con dos diferencias: 1) cómo se crea o usa y 2) cómo se comporta en relación con el resto de la página. Por lo general, creas nodos DOM y los agregas como elementos secundarios de otro elemento. Con el shadow DOM, creas un árbol del DOM con alcance limitado que se adjunta al elemento, pero está separado de sus elementos secundarios reales. Este subárbol con alcance se denomina árbol de sombras. El elemento al que está conectado es su host en sombra. Todo lo que agregues a las sombras se vuelve local para el elemento de alojamiento, incluido <style>. Así es como el Shadow DOM logra el alcance de los estilos de CSS.

Cómo crear un DOM secundario

Un raíz en sombra es un fragmento de documento que se adjunta a un elemento “host”. El acto de adjuntar una raíz de sombra es la forma en que el elemento obtiene 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 la raíz de sombra, pero también puedes usar otras APIs de DOM. Esta es la Web. Tenemos opciones.

La especificación define una lista de elementos que no pueden alojar un árbol de sombras. 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 DOM sombreado (<img>).

Por ejemplo, esto no funciona:

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

Cómo crear Shadow DOM para un elemento personalizado

Shadow DOM es especialmente útil cuando se crean elementos personalizados. Usa Shadow DOM para compartimentar el código HTML, CSS y JS de un elemento y, así, producir un "componente web".

Ejemplo: Un elemento personalizado se adjunta a sí mismo el 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. La primera es que el elemento personalizado crea su propio DOM sombreado cuando se crea una instancia de <fancy-tabs>. Eso se hace en constructor(). En segundo lugar, como estamos creando un elemento raíz de sombra, las reglas de CSS dentro de <style> se limitarán a <fancy-tabs>.

Composición y ranuras

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

En nuestro mundo del desarrollo web, la composición es la forma en que construimos apps de forma declarativa a partir de HTML. Diferentes componentes básicos (<div>, <header>, <form> y <input>) se unen para formar apps. Algunas de estas etiquetas incluso funcionan con otras. La composición es la razón por la que los elementos nativos como <select>, <details>, <form> y <video> son tan flexibles. Cada una de esas etiquetas acepta cierto HTML como elementos secundarios y hace algo especial con ellos. Por ejemplo, <select> sabe cómo renderizar <option> y <optgroup> en widgets de menú desplegable 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 renderizan, pero sí afectan el comportamiento del video. ¡Qué magia!

Terminología: DOM claro y DOM secundario

La composición de Shadow DOM presenta muchos conceptos básicos nuevos en el desarrollo web. Antes de entrar en detalles, estandaricemos algunos aspectos de la terminología para que hablemos el mismo idioma.

DOM ligero

Es el lenguaje de marcado que escribe un usuario de tu componente. Este DOM se encuentra fuera del DOM sombreado 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

El DOM que escribe el autor de un componente. Shadow DOM es local para el componente y define su estructura interna, el CSS con alcance limitado y encapsula los detalles de tu implementación. También puede definir cómo renderizar el marcado creado por el consumidor de tu componente.

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

Árbol del DOM aplanado

El resultado del navegador que distribuye el DOM ligero del usuario en tu DOM sombreado y renderiza el producto final. El árbol aplanado es lo que ves finalmente en DevTools y lo que se renderiza 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>

Shadow DOM compone diferentes árboles del DOM con el elemento <slot>. Los espacios son marcadores de posición dentro de tu componente que los usuarios pueden completar con su propio marcado. Cuando defines uno o más espacios, invitas a que el marcado externo se renderice en el DOM sombreado de tu componente. En esencia, estás diciendo "Renderiza el 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 mueven físicamente el DOM, sino que lo renderizan en otra ubicación dentro del DOM sombreado.

Un componente puede definir cero o más ranuras en su DOM sombreado. Los espacios pueden estar vacíos o proporcionar contenido de resguardo. Si el usuario no proporciona contenido de light DOM, el slot renderiza su contenido de resguardo.

<!-- 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 ranuras con nombre. Los espacios nombrados son espacios específicos en el DOM sombreado a los que los usuarios hacen referencia por nombre.

Ejemplo: Los espacios 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 aplanado se ve de la siguiente manera:

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

Observa que nuestro componente puede controlar diferentes configuraciones, pero el árbol de DOM aplanado sigue siendo el mismo. También podemos cambiar de <button> a <h2>. Este componente se creó para controlar diferentes tipos de elementos secundarios, al igual que lo hace <select>.

Diseño

Existen muchas opciones para aplicar diseño a los componentes web. La página principal puede aplicar diseño a un componente que usa Shadow DOM, definir sus propios diseños o proporcionar hooks (en forma de propiedades personalizadas de CSS) para que los usuarios anulen los valores predeterminados.

Estilos definidos por componentes

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

  • Los selectores CSS de la página externa no se aplican dentro de tu componente.
  • Los diseños definidos dentro no se extienden. Se aplican al elemento host.

Los selectores CSS que se usan dentro de Shadow DOM se aplican de forma local a tu componente. En la práctica, esto significa que podemos volver a usar nombres de ID o clase comunes, sin preocuparnos por conflictos en otras partes de la página. Los selectores CSS más simples son una práctica recomendada dentro de Shadow DOM. También son buenos para el rendimiento.

Ejemplo: Los estilos definidos en una raíz de sombra 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 se aplican al árbol de sombras:

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

¿Te has preguntado cómo el elemento <select> renderiza un widget de varias selecciones (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 autoaplicarse un diseño diferente según los atributos que declares en él. Los componentes web también pueden aplicar estilos a sí mismos con el selector :host.

Ejemplo: Un componente aplica estilo a sí mismo

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

Una trampa con :host es que las reglas de la página superior tienen una especificidad más alta que las reglas de :host definidas en el elemento. Es decir, los estilos externos prevalecen. Esto permite que los usuarios anulen tu diseño de nivel superior desde el exterior. Además, :host solo funciona en el contexto de una raíz de sombra, por lo que no puedes usarlo fuera del DOM de sombra.

La forma funcional de :host(<selector>) te permite segmentar el host si coincide con un <selector>. Esta es una excelente manera para que tu componente encapsule comportamientos que reaccionan a la interacción del usuario o al estado o al estilo de los nodos internos según 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>

Aplica diseño según el contexto

:host-context(<selector>) coincide con el componente si este o cualquiera de sus ancestros coinciden con <selector>. Un uso común para esto es la aplicación 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) aplicaría diseño a <fancy-tabs> cuando sea un descendiente de .darktheme:

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

:host-context() puede ser útil para crear temas, pero un enfoque aún mejor es crear hooks 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 creamos un componente de insignia de nombre:

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

El DOM sombreado del componente puede aplicar diseño a los <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>

Si recuerdas, los <slot> no mueven el DOM ligero del usuario. Cuando los nodos se distribuyen en un <slot>, este renderiza su DOM, pero los nodos permanecen físicamente en su lugar.<slot> Los estilos que se aplicaron antes de la distribución se siguen aplicando después de la distribución. Sin embargo, cuando se distribuye el DOM ligero, puede adquirir estilos adicionales (los que define el DOM sombreado).

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 espacios: un espacio con nombre para los títulos de las pestañas y un espacio para el contenido del panel de la pestaña. Cuando el usuario selecciona una pestaña, marcamos su selección en negrita y revelamos su panel. Para ello, selecciona los nodos distribuidos que tengan el atributo selected. El código JS del elemento personalizado (que no se muestra aquí) agrega ese atributo en el momento correcto.

Aplica diseño a un componente desde el exterior

Existen varias formas de aplicar diseño a un componente desde el exterior. La forma más fácil 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 definidos en Shadow DOM. Por ejemplo, si el usuario escribe el selector fancy-tabs { width: 500px; }, este anulará la regla del componente: :host { width: 650px;}.

Aplicar diseño al componente en sí solo te permitirá hacer lo básico. Pero ¿qué sucede si deseas aplicar diseño a los elementos internos de un componente? Para eso, necesitamos propiedades personalizadas de CSS.

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

Los usuarios pueden modificar los estilos internos si el autor del componente proporciona hooks de diseño con propiedades personalizadas de CSS. Conceptualmente, la idea es similar a <slot>. Creas "marcadores de posición de estilo" para que los usuarios los reemplacen.

Ejemplo: <fancy-tabs> permite que los usuarios anulen 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 valor de fondo, ya que el usuario lo proporcionó. De lo contrario, se establecería de forma predeterminada en #9E9E9E.

Temas avanzados

Creación de raíces de sombra cerradas (se debe evitar)

Hay otra variante del shadow DOM llamada modo “cerrado”. Cuando creas un árbol de sombras cerrado, JavaScript externo no podrá 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 con una raíz de sombra de modo cerrado.

Ejemplo: Creación de un árbol de sombras 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 DOM sombreado muestra [].

A continuación, te resumo por qué nunca debes crear componentes web con {mode: 'closed'}:

  1. Sensación artificial de seguridad. No hay nada que impida que un atacante usurpe Element.prototype.attachShadow.

  2. El modo cerrado impide que el código de tu elemento personalizado acceda a su propio DOM sombreado. Eso es un fracaso total. En su lugar, tendrás que guardar una referencia para más adelante si quieres usar elementos como querySelector(). Esto deshace por completo el propósito 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 tu componente sea menos flexible para los usuarios finales. A medida que compiles componentes web, llegará un momento en el que te olvides de agregar una función. Una opción de configuración. Un caso de uso que el usuario desea. Un ejemplo común es olvidarse de incluir hooks de diseño adecuados para los nodos internos. Con el modo cerrado, los usuarios no pueden anular los valores predeterminados ni ajustar los estilos. Poder acceder a las partes internas del componente es muy útil. En última instancia, los usuarios bifurcarán tu componente, encontrarán otro o crearán el suyo si no hace lo que quieren :(

Cómo trabajar con ranuras en JS

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

evento slotchange

El evento slotchange se activa cuando cambian los nodos distribuidos de un slot. Por ejemplo, si el usuario agrega o quita elementos secundarios del DOM ligero.

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

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

¿Qué elementos se renderizan en un espacio?

A veces, es útil saber qué elementos están asociados con un espacio. Llama a slot.assignedNodes() para encontrar los elementos que renderiza el espacio. La opción {flatten: true} también mostrará el contenido de resguardo de un espacio (si no se distribuyen nodos).

A modo de ejemplo, supongamos que tu DOM en sombra se ve de la siguiente manera:

<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é posición se asigna un elemento?

También es posible responder la pregunta inversa. element.assignedSlot te indica a cuál de las ranuras de componentes está asignado tu elemento.

El modelo de eventos de Shadow DOM

Cuando un evento sube desde el shadow DOM, su objetivo se ajusta para mantener el encapsulamiento que proporciona el shadow DOM. Es decir, los eventos se vuelven a segmentar para que parezcan provenir del componente en lugar de los elementos internos dentro de tu DOM sombreado. Algunos eventos ni siquiera se propagan fuera del DOM sombreado.

Los eventos que cruzan el límite de la sombra son los siguientes:

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

Sugerencias

Si el árbol de sombras está abierto, llamar a event.composedPath() mostrará un array de nodos por los que pasó el evento.

Utiliza eventos personalizados

Los eventos del DOM personalizados que se activan en nodos internos de un árbol de sombras no salen del límite de la sombra, a menos que el evento se cree con la marca 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 es composed: false (predeterminado), los consumidores no podrán escuchar el evento fuera de tu raíz de sombra.

<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 activan dentro del Shadow DOM se ajustan para que parezcan provenir del elemento de host. Por ejemplo, supongamos que haces clic en un <input> dentro de una raíz de sombra:

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

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

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

Si hay varios niveles de Shadow DOM en juego (por ejemplo, un elemento personalizado dentro de otro elemento personalizado), debes desglosar de forma recursiva las raíces de sombra 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 para el enfoque es la opción delegatesFocus: true, que expande el comportamiento de enfoque de los elementos dentro de un árbol de sombras:

  • Si haces clic en un nodo dentro de la DOM de sombra y el nodo no es un área que se pueda enfocar, se enfocará el primer área que se pueda enfocar.
  • Cuando un nodo dentro de Shadow DOM obtiene el foco, :focus se aplica al host, además del elemento enfocado.

Ejemplo: Cómo delegatesFocus: true cambia el comportamiento de 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, se muestra el resultado cuando <x-focus> está enfocado (clic del usuario, entrada con Tab, focus(), etcétera). Se hace clic en "Texto de Shadow DOM en el que se puede hacer clic" o se enfoca el <input> interno (incluida autofocus).

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

delegatesFocus: false y la entrada interna está enfocada.
delegatesFocus: false y el <input> interno están enfocados.
delegatesFocus: Es falso y el enfoque de x obtiene el enfoque (p.ej., tiene tabindex=&#39;0&#39;).
delegatesFocus: false y <x-focus> obtiene el enfoque (p.ej., tiene tabindex="0").
delegatesFocus: false y se hace clic en &quot;Texto de Shadow DOM en el que se puede hacer 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 de Shadow DOM en el que se puede hacer clic" (o se hace clic en otro área vacía dentro del Shadow DOM del elemento).

Sugerencias

Con los años, aprendí algunas cosas sobre la creación de componentes web. Creo que algunas de estas sugerencias te resultarán útiles para crear componentes y depurar el DOM sombreado.

Usa la contención de CSS

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

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

Restablecimiento de los estilos heredables

Los estilos heredables (background, color, font, line-height, etc.) siguen heredando en Shadow DOM. Es decir, atraviesan el límite del shadow DOM de forma predeterminada. Si quieres comenzar con una pizarra en blanco, 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, es útil encontrar elementos personalizados que se usan en la página. Para ello, debes recorrer de forma recursiva el DOM sombreado de todos los elementos que se usan 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 propagar una raíz de sombra con .innerHTML, podemos usar un <template> declarativo. Las plantillas son un marcador de posición ideal para declarar la estructura de un componente web.

Consulta el ejemplo en “Elementos personalizados: compila componentes web reutilizables”.

Historial y compatibilidad con navegadores

Si has estado siguiendo los componentes web durante los últimos años, sabrás que Chrome 35 y versiones posteriores, y Opera, llevan tiempo enviando una versión anterior de DOM sombreado. Blink seguirá admitiendo ambas versiones en paralelo durante un tiempo. La especificación de la versión 0 proporcionó un método diferente para crear una raíz de sombra (element.createShadowRoot en lugar de element.attachShadow de la versión 1). Llamar al método anterior sigue creando una raíz de sombra con semántica de la versión 0, por lo que no se romperá el código existente de la versión 0.

Si te interesa la especificación v0 anterior, consulta los artículos de html5rocks: 1, 2 y 3. También hay una gran comparación de las diferencias entre DOM sombreado v0 y v1.

Navegadores compatibles

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

Para detectar el DOM secundario, verifica la existencia de attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Hasta que la compatibilidad con navegadores esté disponible de forma general, los polyfills de shadydom y shadycss te brindan la función v1. Shady DOM imita el alcance del DOM de Shadow DOM y los polyfills de ShadyCSS para las propiedades personalizadas de CSS y el alcance de 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 usar el complemento o definir el alcance de tus estilos.

Conclusión

Por primera vez, tenemos una primitiva de API que realiza un alcance de CSS y DOM correctos, y tiene una composición real. En combinación con otras APIs de componentes web, como los elementos personalizados, el DOM en sombras proporciona una forma de crear componentes realmente encapsulados sin hacks ni usar paquetes más antiguos, como <iframe>.

No me malinterpretes. Sin duda, el Shadow DOM es un tema complejo. Pero es un monstruo que vale la pena aprender. Pasa un tiempo con ella. Aprende a usarla y haz preguntas.

Lecturas adicionales

Preguntas frecuentes

¿Puedo usar Shadow DOM v1 hoy?

Con un polyfill, sí. Consulta Compatibilidad con navegadores.

¿Qué funciones de seguridad proporciona el DOM sombreado?

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

¿Un componente web tiene que usar Shadow DOM?

De ninguna manera. No es necesario crear componentes web que usen shadow DOM. Sin embargo, la creación de elementos personalizados que usan Shadow DOM significa que puedes aprovechar funciones como el alcance de CSS, la encapsulación de DOM y la composición.

¿Cuál es la diferencia entre las raíces de sombra abiertas y cerradas?

Consulta Raíces de sombras cerradas.