Introducción a Shadow DOM

Introducción

Web Components es un conjunto de estándares de vanguardia que tienen las siguientes características:

  1. Cómo compilar widgets
  2. …que se pueden volver a usar de forma confiable
  3. …y que no dañará las páginas si la siguiente versión del componente cambia los detalles de implementación internos.

¿Esto significa que debes decidir cuándo usar HTML o JavaScript y cuándo usar componentes web? ¡No! HTML y JavaScript pueden crear elementos visuales interactivos. Los widgets son elementos visuales interactivos. Tiene sentido aprovechar tus habilidades de HTML y JavaScript cuando desarrollas un widget. Los estándares de Web Components están diseñados para ayudarte a hacerlo.

Sin embargo, hay un problema fundamental que hace que los widgets creados con HTML y JavaScript sean difíciles de usar: el árbol DOM dentro de un widget no está encapsulado del resto de la página. Esta falta de encapsulamiento significa que la hoja de estilo del documento podría aplicarse accidentalmente a partes dentro del widget, que tu código JavaScript podría modificar accidentalmente partes dentro del widget, que tus IDs podrían superponerse con IDs dentro del widget, etcétera.

Los componentes web se componen de tres partes:

  1. Plantillas
  2. Shadow DOM
  3. Elementos personalizados

Shadow DOM aborda el problema de encapsulación del árbol del DOM. Las cuatro partes de los componentes web están diseñadas para funcionar en conjunto, pero también puedes elegir qué partes de los componentes web usar. En este instructivo, se muestra cómo usar el DOM secundario.

Hola, Shadow World

Con Shadow DOM, los elementos pueden obtener un nuevo tipo de nodo asociado con ellos. Este nuevo tipo de nodo se denomina raíz en las sombras. Un elemento que tiene una raíz en las sombras asociada se denomina host en las sombras. No se renderiza el contenido de un host en sombra, sino el contenido de la raíz en sombra.

Por ejemplo, si tuvieras un marcado como este:

<button>Hello, world!</button>
<script>
var host = document.querySelector('button');
var root = host.createShadowRoot();
root.textContent = 'こんにちは、影の世界!';
</script>

en lugar de

<button id="ex1a">Hello, world!</button>
<script>
function remove(selector) {
  Array.prototype.forEach.call(
      document.querySelectorAll(selector),
      function (node) { node.parentNode.removeChild(node); });
}

if (!HTMLElement.prototype.createShadowRoot) {
  remove('#ex1a');
  document.write('<img src="SS1.png" alt="Screenshot of a button with \'Hello, world!\' on it.">');
}
</script>

tu página se verá de la siguiente manera

<button id="ex1b">Hello, world!</button>
<script>
(function () {
  if (!HTMLElement.prototype.createShadowRoot) {
    remove('#ex1b');
    document.write('<img src="SS2.png" alt="Screenshot of a button with \'Hello, shadow world!\' in Japanese on it.">');
    return;
  }
  var host = document.querySelector('#ex1b');
  var root = host.createShadowRoot();
  root.textContent = 'こんにちは、影の世界!';
})();
</script>

No solo eso, si JavaScript en la página pregunta cuál es el textContent del botón, no obtendrá “こんにちは、影の世界!”, sino “Hello, world!”, ya que el subárbol DOM debajo de la raíz de sombra está encapsulado.

Separa el contenido de la presentación

Ahora, veremos cómo usar Shadow DOM para separar el contenido de la presentación. Supongamos que tenemos esta etiqueta de nombre:

<style>
.ex2a.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.ex2a .boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.ex2a .name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="ex2a outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Este es el lenguaje de marcado. Esto es lo que escribirías hoy. No usa Shadow DOM:

<style>
.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Debido a que el árbol del DOM carece de encapsulamiento, toda la estructura de la etiqueta de nombre se expone al documento. Si otros elementos de la página usan accidentalmente los mismos nombres de clase para aplicar estilos o escribir secuencias de comandos, tendremos problemas.

Podemos evitar que tengas un mal momento.

Paso 1: Oculta los detalles de la presentación

Semánticamente, probablemente solo nos interese lo siguiente:

  • Es una etiqueta con el nombre.
  • El nombre es “Roberto”.

Primero, escribimos un marcado que está más cerca de la semántica real que queremos:

<div id="nameTag">Bob</div>

Luego, colocamos todos los estilos y divs que se usan para la presentación en un elemento <template>:

<div id="nameTag">Bob</div>
<template id="nameTagTemplate">
<span class="unchanged"><style>
.outer {
  border: 2px solid brown;

  … same as above …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div></span>
</template>

En este punto, "Bob" es lo único que se renderiza. Como movimos los elementos DOM de presentación dentro de un elemento <template>, no se renderizan, pero se puede acceder a ellos desde JavaScript. Lo hacemos ahora para propagar la raíz de la sombra:

<script>
var shadow = document.querySelector('#nameTag').createShadowRoot();
var template = document.querySelector('#nameTagTemplate');
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

Ahora que configuramos una raíz de sombra, la etiqueta de nombre se vuelve a renderizar. Si hicieras clic con el botón derecho en la etiqueta de nombre y, luego, inspeccionaras el elemento, verías que es un marcado semántico atractivo:

<div id="nameTag">Bob</div>

Esto demuestra que, con el uso de Shadow DOM, ocultamos los detalles de presentación de la etiqueta de nombre del documento. Los detalles de la presentación se encapsulan en el Shadow DOM.

Paso 2: Separa el contenido de la presentación

Nuestra etiqueta de nombre ahora oculta los detalles de la presentación de la página, pero en realidad no separa la presentación del contenido, ya que, aunque el contenido (el nombre “Roberto”) está en la página, el nombre que se renderiza es el que copiamos en la raíz de la sombra. Si queremos cambiar el nombre en la etiqueta, tendríamos que hacerlo en dos lugares, y es posible que se desincronicen.

Los elementos HTML son composicionales, por ejemplo, puedes colocar un botón dentro de una tabla. Lo que necesitamos aquí es la composición: la etiqueta del nombre debe ser una composición del fondo rojo, el texto “Hola” y el contenido que está en la etiqueta del nombre.

Tú, el autor del componente, defines cómo funciona la composición con tu widget con un elemento nuevo llamado <content>. Esto crea un punto de inserción en la presentación del widget, y el punto de inserción selecciona contenido del host en sombra para presentarlo en ese punto.

Si cambiamos el marcado en el Shadow DOM a lo siguiente:

<span class="unchanged"><template id="nameTagTemplate">
<style>
  …
</style></span>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    <content></content>
  </div>
</div>
<span class="unchanged"></template></span>

Cuando se renderiza la etiqueta de nombre, el contenido del host en sombra se proyecta en el lugar en el que aparece el elemento <content>.

Ahora la estructura del documento es más simple porque el nombre solo se encuentra en un lugar: el documento. Si tu página necesita actualizar el nombre del usuario, solo escribe lo siguiente:

document.querySelector('#nameTag').textContent = 'Shellie';

y eso es todo. El navegador actualiza automáticamente la renderización de la etiqueta de nombre, ya que proyectamos el contenido de la etiqueta de nombre en su lugar con <content>.

<div id="ex2b">

Ahora logramos separar el contenido y la presentación. El contenido está en el documento y la presentación está en el DOM sombreado. El navegador las mantiene sincronizadas automáticamente cuando llega el momento de renderizar algo.

Paso 3: Gana dinero

Si separamos el contenido y la presentación, podemos simplificar el código que manipula el contenido. En el ejemplo de la etiqueta de nombre, ese código solo debe controlar una estructura simple que contiene un <div> en lugar de varios.

Ahora, si cambiamos nuestra presentación, no es necesario que cambiemos el código.

Por ejemplo, supongamos que queremos localizar nuestra etiqueta de nombre. Sigue siendo una etiqueta de nombre, por lo que el contenido semántico del documento no cambia:

<div id="nameTag">Bob</div>

El código de configuración de la raíz de la sombra sigue siendo el mismo. Solo lo que se coloca en la raíz de sombra cambia:

<template id="nameTagTemplate">
<style>
.outer {
  border: 2px solid pink;
  border-radius: 1em;
  background: url(sakura.jpg);
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
  font-family: sans-serif;
  font-weight: bold;
}
.name {
  font-size: 45pt;
  font-weight: normal;
  margin-top: 0.8em;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="name">
    <content></content>
  </div>
  と申します。
</div>
</template>

Esta es una gran mejora con respecto a la situación actual en la Web, ya que el código de actualización de nombres puede depender de la estructura del componente, que es simple y coherente. Tu código de actualización de nombre no necesita conocer la estructura que se usa para la renderización. Si consideramos lo que se renderiza, el nombre aparece en segundo lugar en inglés (después de “Hola. My name is”), pero primero en japonés (antes de “と申します”). Esa distinción no tiene sentido semántico desde el punto de vista de la actualización del nombre que se muestra, por lo que el código de actualización de nombre no tiene que conocer ese detalle.

Crédito adicional: Proyección avanzada

En el ejemplo anterior, el elemento <content> selecciona todo el contenido del host en la sombra. Con el atributo select, puedes controlar lo que proyecta un elemento de contenido. También puedes usar varios elementos de contenido.

Por ejemplo, si tienes un documento que contiene lo siguiente:

<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>

y una raíz de sombra que usa selectores CSS para seleccionar contenido específico:

<div style="background: purple; padding: 1em;">
  <div style="color: red;">
    <content **select=".first"**></content>
  </div>
  <div style="color: yellow;">
    <content **select="div"**></content>
  </div>
  <div style="color: blue;">
    <content **select=".email">**</content>
  </div>
</div>

Los elementos <content select="div"> y <content select=".email"> coinciden con el elemento <div class="email">. ¿Cuántas veces aparece la dirección de correo electrónico de Roberto y en qué colores?

La respuesta es que la dirección de correo electrónico de Roberto aparece una vez y es amarilla.

El motivo es que, como saben las personas que hackean Shadow DOM, la construcción del árbol de lo que realmente se renderiza en pantalla es como una gran fiesta. El elemento de contenido es la invitación que permite que el contenido del documento entre en la parte de renderización de Shadow DOM en el escenario. Estas invitaciones se entregan en orden. Quien recibe una invitación depende de a quién está dirigida (es decir, el atributo select). Una vez invitado, el contenido siempre acepta la invitación (¿quién no lo haría?) y se publica. Si se vuelve a enviar una invitación a esa dirección, no habrá nadie en casa y no llegará a tu fiesta.

En el ejemplo anterior, <div class="email"> coincide con el selector div y el selector .email, pero como el elemento de contenido con el selector div aparece antes en el documento, <div class="email"> va a la parte amarilla y nadie está disponible para ir a la parte azul. (Es posible que por eso esté tan azul, aunque la miseria ama la compañía, así que nunca se sabe).

Si no se invita a un elemento a ningún elemento, no se renderizará. Eso es lo que sucedió con el texto “Hola, mundo” en el primer ejemplo. Esto es útil cuando deseas lograr una renderización radicalmente diferente: escribe el modelo semántico en el documento, al que pueden acceder las secuencias de comandos de la página, pero ocúltalo para la renderización y conéctalo a un modelo de renderización realmente diferente en Shadow DOM con JavaScript.

Por ejemplo, HTML tiene un selector de fecha agradable. Si escribes <input type="date">, obtendrás un calendario emergente ordenado. Pero, ¿qué sucede si quieres permitir que el usuario elija un rango de fechas para sus vacaciones en la isla de postre (ya sabes… con hamacas hechas de Red Vines)? Configura tu documento de la siguiente manera:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

pero crea un DOM sombreado que use una tabla para crear un calendario elegante que destaque el rango de fechas, etcétera. Cuando el usuario hace clic en los días del calendario, el componente actualiza el estado en las entradas startDate y endDate. Cuando el usuario envía el formulario, se envían los valores de esos elementos de entrada.

¿Por qué incluí etiquetas en el documento si no se renderizarán? El motivo es que, si un usuario ve el formulario con un navegador que no es compatible con Shadow DOM, el formulario aún se puede usar, solo que no es tan atractivo. El usuario ve algo como lo siguiente:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

Aprobaste la prueba Shadow DOM 101

Esos son los conceptos básicos de Shadow DOM. ¡Aprobaste el curso introductorio! Puedes hacer mucho más con Shadow DOM. Por ejemplo, puedes usar varias sombras en un host de sombras, sombras anidadas para el encapsulamiento o diseñar tu página con vistas basadas en modelos (MDV) y Shadow DOM. Y los componentes web son más que solo Shadow DOM.

Explicaremos estos conceptos en publicaciones posteriores.