Shadow DOM v1: Componentes web independientes

El Shadow DOM permite que los desarrolladores web creen un DOM y un CSS compartimentados para los componentes web.

Resumen

El Shadow DOM elimina la fragilidad de compilar apps web. La fragilidad proviene de la naturaleza global de HTML, CSS y JS. A lo largo de los años, hemos inventado una cantidad exorbitante de herramientas para evitar los problemas. Por ejemplo, cuando usas un ID o una clase de HTML nuevos, no se puede saber si entrarán 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 salen de control y el rendimiento puede verse afectado. La lista continúa.

Shadow DOM corrige CSS y DOM. Introduce estilos con alcance en la plataforma web. Sin herramientas ni convenciones de nombres, puedes unir CSS con lenguaje de marcado, ocultar detalles de implementación y crear componentes independientes en JavaScript básico.

Introducción

El 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 que crees componentes web que usen el DOM de sombra. Sin embargo, cuando lo haces, aprovechas sus beneficios (alcance de CSS, encapsulamiento de DOM, composición) y creas elementos personalizados reutilizables, que son resilientes, altamente configurables y extremadamente reutilizables. Si los elementos personalizados son la forma de crear un nuevo HTML (con una API de JS), el 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.

El Shadow DOM está diseñado como una herramienta para compilar apps basadas en componentes. Por lo tanto, ofrece soluciones para problemas comunes en el desarrollo web:

  • DOM aislado: El DOM de un componente es autónomo (p.ej., document.querySelector() no devolverá nodos en el DOM de sombra del componente).
  • CSS con alcance: El CSS definido dentro del shadow DOM tiene alcance para él. Las reglas de estilo no se filtran y los estilos de página no se mezclan.
  • Composición: Diseña una API declarativa basada en lenguaje de marcado para tu componente.
  • Simplifica el CSS: El DOM con alcance significa que puedes usar selectores CSS simples, nombres de ID o clases 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).

Demostración de fancy-tabs

En este artículo, haré referencia a un componente de demostración (<fancy-tabs>) y a fragmentos de código de él. 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 el shadow DOM?

Información general sobre el DOM

HTML impulsa 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, por sí solo, HTML no es tan útil. Para los humanos, es fácil 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, hace muchas cosas interesantes. Una de las cosas que hace es transformar el HTML del autor en un documento activo. Básicamente, para comprender la estructura de la página, el navegador analiza el HTML (cadenas de texto estáticas) 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 creamos, los nodos producidos por el navegador contienen propiedades, métodos y, lo mejor de todo, pueden ser manipulados por programas. Por eso, 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á muy bien. Entonces, ¿qué es el shadow DOM?

DOM… en las sombras

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

Crea un DOM paralelo

Una raíz de 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 del DOM. Esta es la Web. Tenemos opciones.

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

El shadow DOM es particularmente útil cuando se crean elementos personalizados. Usa el shadow DOM para compartimentar el HTML, el CSS y el JS de un elemento, lo que produce un "componente web".

Ejemplo: Un elemento personalizado se adjunta el Shadow DOM a sí mismo, lo que 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í suceden un par de cosas interesantes. La primera es que 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 estamos creando una raíz de sombra, las reglas de CSS dentro de <style> se aplicarán a <fancy-tabs>.

Composición y ranuras

La composición es una de las funciones menos comprendidas del DOM de sombra, pero podría decirse que es la más importante.

En nuestro mundo del desarrollo web, la composición es la forma en que creamos apps, de manera declarativa a partir de HTML. Los diferentes componentes básicos (<div>, <header>, <form> y <input>) se unen para formar apps. Algunas de estas etiquetas incluso funcionan entre sí. 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 cierto código 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 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 frente a DOM oscuro

La composición del shadow DOM introduce un montón de conceptos básicos nuevos en el desarrollo web. Antes de entrar en detalles, estandaricemos algunos términos para que hablemos el mismo idioma.

DOM ligero

El lenguaje de marcado que escribe un usuario de tu componente. Este DOM se encuentra 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

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

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

Árbol del DOM aplanado

Es el resultado de la distribución que hace el navegador del DOM claro del usuario en tu DOM en la sombra, lo que renderiza el producto final. El árbol aplanado es lo que ves en última instancia en las Herramientas para desarrolladores 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>

El 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 lenguaje de marcado. Si defines uno o más espacios, invitas al lenguaje de marcado externo a renderizarse en el DOM de sombra de tu componente. En esencia, 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 a entrar. Estos elementos se denominan nodos distribuidos. Conceptualmente, los nodos distribuidos pueden parecer un poco extraños. Los espacios no mueven físicamente el DOM, sino que lo renderizan en otra ubicación dentro del DOM de sombra.

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

<!-- 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 con nombre son huecos específicos en tu DOM de sombra a los que los usuarios hacen referencia por nombre.

Ejemplo: Los espacios en el DOM de sombra 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 del 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, tal como lo hace <select>.

Diseño

Existen muchas opciones para aplicar diseño a los componentes web. La página principal puede definir el diseño de un componente que use el DOM de sombra, 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 el componente

Sin duda, la función más útil del shadow DOM es el CSS con alcance:

  • Los selectores CSS de la página externa no se aplican dentro de tu componente.
  • Los estilos definidos en el interior no se filtran. Tienen alcance para el elemento host.

Los selectores CSS que se usan dentro del DOM de sombra 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 los conflictos en otras partes de la página. Los selectores CSS más simples son una práctica recomendada dentro del Shadow DOM. También son buenos para el rendimiento.

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

#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 aplicarse diseño a sí mismo de manera diferente según los atributos que declares en él. Los componentes web también pueden aplicarse diseño a sí mismos con el selector :host.

Ejemplo: Un componente que se aplica estilo a sí mismo

<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 más alta que las reglas de :host definidas en el elemento. Es decir, ganan los estilos externos. Esto permite que los usuarios anulen el diseño de nivel superior desde el exterior. Además, :host solo funciona en el contexto de una raíz de shadow, por lo que no puedes usarlo fuera del DOM de shadow.

La forma funcional de :host(<selector>) te permite segmentar el host si coincide con un <selector>. Esta es una excelente manera de que tu componente encapsule comportamientos que reaccionen a la interacción del usuario o que apliquen diseño o estilo a 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 principales coinciden con <selector>. Un uso común de esto es aplicar temas según el entorno de un componente. Por ejemplo, muchas personas aplican temas con una clase a <html> o <body>:

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

:host-context(.darktheme) aplicaría estilo a <fancy-tabs> cuando es un descendiente de .darktheme:

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

:host-context() puede ser útil para aplicar temas, pero un enfoque aún mejor es crear hooks de estilo con propiedades personalizadas de CSS.

Cómo aplicar 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 de sombra del componente puede aplicar diseño a los elementos <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 de luz del usuario. Cuando los nodos se distribuyen en un <slot>, el <slot> renderiza su DOM, pero los nodos permanecen físicamente en su lugar. Los estilos que se aplicaban antes de la distribución se siguen aplicando después de ella. Sin embargo, cuando se distribuye el DOM claro, puede adoptar estilos adicionales (los definidos por el DOM de sombra).

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 otra para el contenido del panel de pestañas. Cuando el usuario selecciona una pestaña, ponemos en negrita su selección y mostramos su panel. Para ello, se seleccionan los 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

Existen un par de formas de aplicar diseño a un componente desde el exterior. La forma más sencilla 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;}.

Aplicar diseño al componente en sí solo te llevará hasta cierto punto. Pero ¿qué sucede si quieres aplicar diseño a los elementos internos de un componente? Para eso, necesitamos propiedades personalizadas de CSS.

Crea 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 anulen.

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

Temas avanzados

Crear raíces de sombra cerradas (se debe evitar)

Hay otra variante del DOM de sombra llamada modo "cerrado". Cuando creas un árbol de sombra cerrado, el JavaScript externo no podrá acceder al DOM interno de tu componente. Esto es similar a cómo funcionan los elementos nativos, como <video>. JavaScript no puede acceder al DOM de sombra de <video> porque el navegador lo implementa con una raíz de sombra de modo cerrado.

Ejemplo: Crea un árbol de shadow DOM 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 devoluciones de null
  • Event.composedPath() para eventos asociados con elementos dentro del DOM en la sombra, devuelve []

Aquí tienes un resumen de por qué nunca deberías crear componentes web con {mode: 'closed'}:

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

  2. El modo cerrado impide que el código de tu elemento personalizado acceda a su propio DOM de sombra. Eso es un fracaso total. En su lugar, deberás almacenar una referencia para más adelante si quieres usar elementos como querySelector(). Esto anula 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 olvidarás agregar una función. Es una opción de configuración. Un caso de uso que el usuario quiere Un ejemplo común es olvidar 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 los componentes internos 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 espacios en JS

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

evento slotchange

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

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 averiguar qué elementos renderiza el espacio publicitario. La opción {flatten: true} también devolverá el contenido alternativo de un espacio (si no se distribuyen nodos).

Por ejemplo, supongamos que tu shadow DOM se ve de la siguiente manera:

<slot><b>fallback content</b></slot>
UsoLlamarResultado
<my-component>component text</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 espacios de componentes se asigna tu elemento.

El modelo de eventos del Shadow DOM

Cuando un evento se propaga desde el shadow DOM, su destino se ajusta para mantener la encapsulación que proporciona el shadow DOM. Es decir, los eventos se reorientan para que parezca que provienen del componente y no de elementos internos dentro de tu DOM de sombra. Algunos eventos ni siquiera se propagan fuera del DOM de sombra.

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

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

Sugerencias

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

Utiliza eventos personalizados

Los eventos personalizados del DOM que se activan en nodos internos de un árbol de sombra no se propagan fuera del límite de 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 el modelo de eventos del DOM de sombra, los eventos que se activan dentro del DOM de sombra se ajustan para que parezca que provienen del elemento de hosting. Por ejemplo, supongamos que haces clic en un <input> dentro de una raíz de shadow:

<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 modo cerrado), también podrás acceder al nodo interno que recibió 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 profundizar de forma recurrente en 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 delegatesFocus: true, que expande el comportamiento de enfoque de los elementos dentro de un árbol de sombra:

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

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

Comportamiento de delegatesFocus: true.

Arriba se muestra el resultado cuando se enfoca <x-focus> (clic del usuario, tabulación, focus(), etc.), se hace clic en "Texto del DOM de sombra en el que se puede hacer clic" o se enfoca el <input> interno (incluido autofocus).

Si estableces delegatesFocus: false, esto es lo que verás en su lugar:

delegatesFocus: false y la entrada interna está enfocada.
delegatesFocus: false y el <input> interno está enfocado.
delegatesFocus: false y x-focus
    obtiene el enfoque (p.ej., tiene tabindex='0').
delegatesFocus: false y <x-focus> reciben el enfoque (p.ej., tienen tabindex="0").
delegatesFocus: false y se hace clic en &quot;Texto de Shadow DOM en el que se puede hacer clic&quot; (o en otra área vacía dentro del Shadow DOM del elemento).
Se hace clic en delegatesFocus: false y en "Texto de Shadow DOM en el que se puede hacer clic" (o en otra área vacía dentro del Shadow DOM del elemento).

Sugerencias

A lo largo de 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 Shadow DOM.

Usa 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 mejorar el rendimiento:

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

Cómo restablecer los estilos heredables

Los estilos heredables (background, color, font, line-height, etc.) se siguen heredando en el shadow DOM. Es decir, perforan el límite del DOM de sombra de forma predeterminada. Si quieres empezar desde cero, usa all: initial; para restablecer los estilos heredables a su valor inicial cuando crucen el límite de 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 recurrente el shadow DOM 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('*'));

Crea 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, han estado enviando una versión anterior del Shadow DOM durante algún tiempo. Blink seguirá admitiendo ambas versiones en paralelo durante un tiempo. La especificación v0 proporcionaba un método diferente para crear una raíz de shadow (element.createShadowRoot en lugar de element.attachShadow de v1). Llamar al método anterior sigue creando una raíz de shadow con la semántica de v0, por lo que el código de v0 existente no se verá afectado.

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

Navegadores compatibles

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

Para detectar el shadow DOM, verifica la existencia de 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 shadycss polyfills, 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 ajustar o definir el alcance de tus estilos.

Conclusión

Por primera vez, tenemos una primitiva de API que realiza un alcance de CSS y de DOM adecuados, y tiene una composición real. En combinación con otras APIs de componentes web, como los elementos personalizados, el shadow DOM proporciona una forma de crear componentes verdaderamente encapsulados sin hackeos ni el uso de elementos antiguos, como <iframe>.

No me malinterpretes. El Shadow DOM es, sin duda, una bestia compleja. Pero es una bestia que vale la pena aprender. Dedícale un tiempo. Aprende sobre el tema 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 shadow DOM?

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

¿Un componente web debe usar el shadow DOM?

No. No es necesario que crees componentes web que usen el DOM de sombra. Sin embargo, crear elementos personalizados que usen 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 sombra cerradas.