Seleccionar página
Los elementos HTML que incluimos dentro de un Custom Tag se denominan Light DOM. Funciona de forma diferente si tenemos o no un Shadow DOM definido. En el último artículo donde analizamos a fondo el Shadow DOM todos los ejemplos los hemos creado sobre elementos que no contenían otras etiquetas HTML en su interior, es decir, no tenían un Light DOM. Esto no ha sido casualidad, ya que al llamar a attachShadow(), se eliminan la renderización los elementos hijos que el componente pudiera contener. Es decir, el Light DOM se renderiza como un HTML normal cuando no hay Shadow DOM y queda oculto cuando el Shadow DOM existe, utilizándose en este caso para aportar información a nuestro componente.

Hoy os invitamos a descubrir las características de este Light DOM y cómo podemos utilizarlo para pasar información a nuestros componentes, configurarlos y definir cómo queremos que sea su comportamiento.

El Light DOM con y sin Shadow DOM

En este primer ejemplo podemos ver un Light DOM que se renderiza como un HTML normal dentro del componente ya que no hay presente un Shadow DOM:

<script>
  customElements.define ('site-name', class extends HTMLElement {
  });
</script>
<site-name>
  <b>www.todojs.com</b>
</site-name>
 

En este otro ejemplo se puede ver cómo un contenido pasa al Light DOM y deja de estar renderizado cuando se le añade un Shado DOM, aunque se mantiene como parte del DOM principal:

<div id="test">
  <p>DIV content</p>
</div>
<button id="attach">AttachShadow</button>
<button id="find">document.querySelector('div > P')</button>
<pre></pre>
<script>
  const result = document.querySelector ('pre');

  const attach = document.querySelector ('#attach');
  attach.addEventListener ('click', function () {
    const div = document.querySelector ('#test');
    div.attachShadow ({mode : 'open'});
    div.shadowRoot.innerHTML = '<p>DIV shadow content</p>';
    result.innerHTML         = '';
  });

  const find = document.querySelector ('#find');
  find.addEventListener ('click', function () {
    const element   = document.querySelector ('div > p');
    result.innerHTML = element.innerText;
  });
</script>

Vamos a repasar este ejemplo paso a paso.

  • Al empezar se muestra el texto DIV content. Este es el Light DOM cuando todavía no se ha añadido un Shadow DOM
  • Cuando se pulsa primer botón y es añadido el Shadow DOM, entonces ese contenido desaparece de la pantalla
  • En su lugar aparecerá DIV shadow content, que es el contenido que ahora tiene el elemento en su Shadow DOM
  • Lo más sorprendente, es que el texto original sigue ahí, en el DOM, y si hacemos un querySelector('div > p') obtendremos la referencia del elemento original que tiene el texto DIV content, que -aunque está oculto- sigue estando en el DOM dentro del elemento.

Ahora comprendemos como funciona el Light DOM con y sin Shandow DOM: el Light DOM se renderiza como un HTML normal cuando no hay Shadow DOM y queda oculto cuando este existe.

Diferencia entre Light DOM y Shadow DOM

light-dom & shadow-domComo hemos visto, el Light DOM puede existir sin y con Shadow DOM, pero es en presencia de este segundo cuando cobra especial interés para nuestros componentes. En esta imagen se puede ver la forma en la que las herramientas de desarrollo de Chrome muestran ambos elementos cuando coinciden en un elemento.

En primer lugar, vemos #shadow-root (open) que indica que a partir de aquí y hacia abajo tendremos el Shandow DOM que está desconectado del DOM general. Al mismo nivel, un poco más abajo, tenemos el <P>DIV content</p> que corresponde al Light DOM y que pertenece al DOM general.

Las diferencias entre ambos son:

  • Light DOM:
    • Si hay un Shadow DOM no se renderiza. Si estaba renderizado antes de la creación del Shadow DOM se oculta en cuanto este se crea.
    • Es accesible desde el DOM superior y puede ser modificado sin limitaciones tanto desde el DOM general como desde el componente.
  • Shadow DOM:
    • Se renderiza como la parte visible del componente.
    • Su contenido no es accesible directamente desde el DOM superior y, dependiendo de si se ha creado en modo open o closed, se puede o no acceder desde fuera por medio de la propiedad .shadowRoot.

La gran ventaja del Light DOM es que es accesible desde dentro y fuera del componente, y por lo tanto, es un espacio de comunicación y colaboración entre ambos contextos, entre el usuario del componente y el código interno del componente web. Al no renderizarse en presencia de un Shadow DOM, puede utilizarse de la forma que queramos, sin preocuparnos de si se va a mostrar al usuario final.

Acceso al Light DOM

Desde el DOM superior

Para acceder al Light DOM no hay que hacer nada especial, simplemente localizamos el Custom Tag y accedemos a su contenido, que son nodos hijos, como otros cualquiera. Esto ocurre tanto si existe un Shadow DOM como si no.

<button id="getText">Random Lorem Ipsum</button>
<wc-words></wc-words>
<script>
  document.querySelector ('#getText').addEventListener ('click', function () {
    const words      = parseInt (Math.random () * 200);
    const paragraphs = parseInt (words / 20);
    fetch ('http://www.randomtext.me/api/lorem/p-' + paragraphs + '/1-' + words)
      .then (function (response) {
        if (response.status === 200) {
          return response.json ();
        }
      }).then (function (data) {
      document.querySelector ('wc-words').innerHTML = data.text_out;
    });
  });

  customElements.define ('wc-words', class extends HTMLElement {
  });
</script>

En este sencillo ejemplo obtenemos un texto aleatorio y lo incluimos dentro del Custom Tag por medio de .innertHTML.

Desde el componente

El Light DOM corresponde a elemento this de nuestro componente, y podemos utilizar todos los métodos habituales para el manejo del DOM para consultarlo y manipularlo. Recordemos que para acceder al Shadow DOM tenemos que utilizar this.shadowRoot o el valor devuelto por this.attachShadow().

El único problema es el contenido de nuestro elemento no está disponible en el constructor, ni tampoco en la llamada al método connectedCallback(). El Ligth DOM está accesible justo después de la llamada a connectedCallback(). Por ello tenemos que hacer uso de setTimeout() para poder acceder a él justo después de la llamada al callback de conexión.

<script>
  customElements.define ('wc-words', class extends HTMLElement {
    constructor () {
      super ();
      this.attachShadow ({mode : 'open'});
      this.shadowRoot.innerHTML = '<p>0</p>';
    }
    
    connectedCallback () {
      setTimeout (this.count.bind (this), 10);
    }
    
    count () {
      const texts = [ ...this.querySelectorAll ('p') ]
        .map ((p) => p.innerText);
      const count = texts
        .join (' ')
        .replace (/[.!?\\-]|\s|\n|\r/gi, " ")
        .replace (/[ ]{2,}/gi, " ")
        .split (' ')
        .filter (function (str) {
          return str.trim () !== ""
        })
        .length;
      
      this.shadowRoot.innerHTML = '<h3>' + count + ' words</h3>' + this.innerHTML;
    }
  });
</script>
<wc-words>
  <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce quis nulla vel est
    dignissim lobortis id eget erat. Aliquam eu suscipit risus, ac porttitor dolor.
    Sed condimentum laoreet sodales. Proin condimentum massa justo, eget finibus
    ligula tincidunt et. Aliquam malesuada, lectus at congue sollicitudin, justo quam
    dapibus eros, non laoreet nunc nunc sit amet ante. </p>
  <p>Nulla at tortor ac augue facilisis euismod in ac est. Integer sed arcu id justo
    venenatis elementum ut eget nulla. Duis aliquet viverra urna, eu fringilla mauris
    finibus a. Maecenas vel vulputate neque, ac rhoncus massa. Maecenas lobortis
    nulla at tempor feugiat. Nulla eu bibendum elit. Cras bibendum elit erat, non
    suscipit leo feugiat sit amet. Sed tempus dapibus diam, ac sagittis nulla aliquam
    vitae. Donec quis erat.</p>
</wc-words>
 

Este ejemplo se muestra el número de palabras que contiene el texto que está dentro del Light DOM, para acceder al mismo se ha incluido una llamada a setTimeout() sin tiempo (hemos puesto un 1 para evitar problemas) y que llama al método que hace el conteo de palabras.

Copy Light DOM into Shadow DOMEl contenido ha sido copiado dentro del Shadow DOM para que el texto siga siendo visible, por lo que finalmente el contenido está duplicado, una vez el en Light DOM y otro en el DOM en la sombra. Esta es una operación de copia del contenido es muy común para que se muestre parte o todo el Light DOM dentro de nuestro componente.

Un poco más adelante explicaremos otros medios alternativos para esta misma operación de copia del contenido de una parte a otra del componente.

Observar cambios en el Light DOM

En este último ejemplo sólo se lee el contenido del Light DOM que está presente en el momento de creación del componente, y cualquier cambio en el contenido del Custom Tag pasará desapercibido. Para poder observar los cambios de un elemento del DOM se utiliza el MutationObserver, que nos avisará de cualquier modificación que se produzca.

<button id="getText">Random Lorem Ipsum</button>
<wc-words></wc-words>
<script>
  document.querySelector ('#getText').addEventListener ('click', function () {
    const words      = parseInt (Math.random () * 200);
    const paragraphs = parseInt (words / 20);
    fetch ('https://www.randomtext.me/api/lorem/p-' + paragraphs + '/1-' + words)
      .then (function (response) {
        if (response.status === 200) {
          return response.json ();
        }
      }).then (function (data) {
      document.querySelector ('wc-words').innerHTML = data.text_out;
    });
  });

  customElements.define ('wc-words', class extends HTMLElement {
    constructor () {
      super ();
      this.attachShadow ({mode : 'open'});
      this.shadowRoot.innerHTML = '<h3>0 words</h3>';
    }
    connectedCallback () {
      new MutationObserver (
        this.count.bind (this)
      ).observe (
        this,
        {attributes : true, childList : true, characterData : true}
      );
    }
    count () {
      const texts = [...this.querySelectorAll ('p')]
        .map ((p) => p.innerText);
      const count = texts
        .join (' ')
        .replace (/[.!?\\-]|\s|\n|\r/gi, " ")
        .replace (/[ ]{2,}/gi, " ")
        .split (' ')
        .filter (function (str) {
          return str.trim () !== ""
        })
        .length;
      this.shadowRoot.innerHTML = '<h3>' + count + ' words</h3>' + this.innerHTML;
    }

  });
</script>
 

En este ejemplo no se inserta texto hasta que no se pulsa el botón. Cuando esto ocurre, el MutationObserver nos informa de este cambio y se hace el conteo de palabras en el texto, además de copiarlo dentro del Shadow DOM para que sea mostrado dentro de nuestro componente.

Nota: MutationObserver no va funcionar como esperamos si estamos utilizando el polyfill de Shadow DOM, ya que, aunque se reescriben prácticamente todos los métodos de acceso para diferenciar el Light DOM del Shadow DOM, MutationObserver se mantiene con el código original y observará el Shadow DOM y no el Light DOM que este shim mantiene oculto.

Slots

En el ejemplo anterior hemos copiado el contenido del Light DOM a una parte del Shadow DOM, como esta es una operación muy común, se han creado los Slot, un mecanismo estándar para copiar contenido del Light DOM en alguna parte del Shadow DOM. Es una forma muy cómoda de operar.

Podemos modificar el ejemplo anterior para que utilice slot de la siguiente forma:

<button id="getText">Random Lorem Ipsum</button>
<wc-words></wc-words>
<script>
  document.querySelector ('#getText').addEventListener ('click', function () {
    const words      = parseInt (Math.random () * 200);
    const paragraphs = parseInt (words / 20);
    fetch ('https://www.randomtext.me/api/lorem/p-' + paragraphs + '/1-' + words)
      .then (function (response) {
        if (response.status === 200) {
          return response.json ();
        }
      }).then (function (data) {
      document.querySelector ('wc-words').innerHTML = data.text_out;
    });
  });
  
  customElements.define ('wc-words', class extends HTMLElement {
    constructor () {
      super ();
      this.attachShadow ({mode : 'open'});
      this.shadowRoot.innerHTML =
        '<h3>0 words</h3>' +
        '<slot></slot>';
      this.shadowRoot
          .querySelector ('slot')
          .addEventListener ('slotchange', this.count.bind (this));
    }
    count () {
      const texts = [ ...this.querySelectorAll ('p') ]
        .map ((p) => p.innerText);
      const count = texts
        .join (' ')
        .replace (/[.!?\\-]|\s|\n|\r/gi, " ")
        .replace (/[ ]{2,}/gi, " ")
        .split (' ')
        .filter (function (str) {
          return str.trim () !== ""
        })
        .length;
      this.shadowRoot.querySelector ('h3').innerHTML = count + ' words';
    }
  });
</script>

En este ejemplo hemos creado un elemento slot en el Shadow DOM, es el elemento donde se copiará el contenido del Light DOM. Para observar cambios en el slot hemos utilizado el evento slotchange que nos informa de cualquier modificación en el contenido.

Este slot no tienen ningún nombre y por lo tanto se copia en él todo el contenido del Light DOM. Este formato es el más cómodo y sencillo de copiar el contenido, pero no es el único.

Slot con nombre

Pero también podemos crear uno o varios slot con nombre y desde el Light DOM decidir a cuál de ellos enviamos el contenido. Esta es una forma de enviar partes concretas del Light DOM a elementos concretos del Shadow DOM.

<my-component>
  <span slot="name">Willian</span>
  <span slot="last-name">Shakespeare</span>
  <record-type>author</record-type>
</my-component>
<script>
  class MyComponent extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({mode: 'open'});
      this.shadowRoot.innerHTML =
        '<p>' +
          '<slot name="last-name">My default text</slot>, ' +
          '<slot name="name">My default text</slot>' +
        '</p>';
    }
  }
  customElements.define('my-component', MyComponent);
</script>

En este ejemplo, hemos creado dos slot en el Shadow DOM y en el Light DOM hemos incluido dos span donde hemos incluido el atributo slot con el nombre de cada uno de los elementos a donde queremos enviar cada uno de los contenidos.

Debemos tener en cuenta que el uso con nombre produce un cierto acoplamiento entre ambas partes, ya que el usuario de nuestro componente debe saber los nombres que hemos dado a los slot para que pueda incluirlos en el Light DOM de forma correcta. Puede no ser un gran problema, pero debemos tenerlo en cuenta.

Por último, hay que aclarar que, aunque hay algunas referencias que asocian los slot a los Template (que próximamente analizaremos a fondo), realmente se pueden utilizar sin problemas en cualquier Shadow DOM, usemos o no la etiqueta Template.

Ejemplo

A fin de mostrar con más claridad cómo el Light DOM puede ser utilizado para configurar nuestros componentes y como área de comunicación entre el interior del componente y el exterior, hemos implementado ese sencillo carrusel de imágenes que configuramos incluyendo etiquetas source dentro del Custom Tag:

<super-carousel lapse="2">
  <source src="https://via.placeholder.com/150.png">
  <source src="https://via.placeholder.com/150.png/f90/000">
  <source src="https://via.placeholder.com/150.png/090/fff">
</super-carousel>
<p>
  <button id="add">add</button>
  <button id="remove">remove</button>
</p>
<script>
  document.querySelector ('#add').addEventListener ('click', function () {
    const source = document.createElement ('source');
    source.src   = 'https://via.placeholder.com/150.png/'
                   + (0 | Math.random () * 255).toString (16)
                   + (0 | Math.random () * 255).toString (16)
                   + (0 | Math.random () * 255).toString (16)
                   + '/000';
    document.querySelector ('super-carousel').appendChild (source);
  });
  document.querySelector ('#remove').addEventListener ('click', function () {
    const source = document.querySelector ('super-carousel > source:last-of-type');
    if (!source) {
      return;
    }
    document.querySelector ('super-carousel').removeChild (source);
  });

  class SuperCarousel extends HTMLElement {
    constructor () {
      super ();
      this.attachShadow ({mode : 'open'});
      this.shadowRoot.innerHTML = '<img id="content">';
    }

    connectedCallback () {
      new MutationObserver (this.show.bind (this))
        .observe (this, {childList : true});
      this.show ();
    }

    show (mutation) {
      clearTimeout (this._timer);
      let active = this.querySelector ('source[active]') ||
                   this.querySelector ('source');
      if (!active) {
        this.shadowRoot.querySelector ('img').src = '';
        return;
      }
      let next = this.querySelector ('source[active] + source') ||
                 this.querySelector ('source');
      active.removeAttribute ('active');
      next.setAttribute ('active', '');
      this.shadowRoot.querySelector ('img').src = next.src;
      const lapse = (parseInt (this.getAttribute ('lapse')) * 1000) || 1000;
      this._timer = setTimeout (this.show.bind (this), lapse);
    }
  }
  customElements.define ('super-carousel', SuperCarousel);
</script>
 

Hemos incluido dos botones para adjuntar nuevas imágenes y para eliminarlas, añadiendo y quitando etiquetas source del Light DOM. De esta forma configuramos el componente con las imágenes que tiene que ir mostrado sucesivamente.

Conclusiones

Es posible que el Light DOM quede un poco eclipsado por la fama y funcionalidad del Shadow DOM, pero es una pieza muy interesante e importante de nuestros componentes web.

En primer lugar, es posible que no siempre queramos tener un Shadow DOM y que las etiquetas que contiene nuestro Custom Tag queramos que se rendericen sin problemas. Es una opción interesante que no podemos desaprovechar. No debemos abusar del Shadow DOM.

También es posible que queramos utilizar el Light DOM como mecanismo para incluir contenido a nuestro componente de forma selectiva. Tanto si lo hacemos directamente nosotros seleccionando cada uno de los elementos, como si utilizamos slot, con o sin nombre, este es un mecanismo muy sencillo de utilizar y podemos dar contenido a nuestro componente sin problemas.

Por último, y no por ello menos importante, es posible que queramos configurar el comportamiento de nuestro componente y los atributos no sean suficientemente flexibles para ello. En este caso -de nuevo- el Light DOM nos ofrece un mecanismo muy sencillo de comunicación del componente web con su exterior. Tanto el DOM como el propio componente puede leer y escribir en este espacio común, y por lo tanto es una característica muy flexible para nuestros programas.

Novedades

HTTP2 para programadores. Enviar mensajes del servidor al cliente con Server Sent Event (sin WebSockets)

HTTP2 para programadores. Enviar mensajes del servidor al cliente con Server Sent Event (sin WebSockets)

En esta charla, organizada por MadridJS, Pablo Almunia nos muestra cómo la mayoría de nosotros cuando oímos hablar por primera vez de HTTP2 nos ilusionamos con las posibilidades que presumiblemente se abrían para el desarrollo de soluciones web avanzadas y cómo muchos nos sentimos defraudados con lo que realmente se podía implementar.

En esta charla podemos ver cómo funciona el HTTP2, que debemos tener en cuenta en el servidor para hace uso de este protocolo y, sobre todo, cómo podemos enviar información desde el servidor al cliente de forma efectiva y fácil. Veremos con detenimiento cómo por medio de los Server-Sent Events (SSE) podemos recibir en el cliente datos enviados desde el servidor sin utilizar websocket, simplificando enormemente la construcción de aplicaciones con comunicación bidireccional.

Observables en Javascript con Proxies

Observables en Javascript con Proxies

En esta charla, organizada por MadridJS, Pablo Almunia nos habla de la observación reactiva de objetos en Javascript por medio de Proxy. Se describe paso a paso cómo funcionan los Proxies y en que casos pueden ser nuestro mejor aliado. Veremos que no hay que tenerles miedo, son bastante sencillos de utilizar, y nos ofrecen una gran abanico de posibilidades.

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

Aplicaciones JAMStack, SEO friendly y escalables con NextJS

En esta charla de Madrid JS, Rafael Ventura nos describe las funcionalidades clave de NextJS, nos muestra en vivo cómo desarrollar una completa aplicación JAMStack con Server Side Rendering (SSR) y Static Site Generation (SSG) y termina mostrando como publicar esta aplicación en Vercel.

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

Stencil JS: mejora el Time To Market de tu producto, por Rubén Aguilera

En esta charla Rubén Aguilera nos cuenta los problemas que tienen muchas empresas a la hora de sacar productos accesibles, vistosos y usables en el Time To Market que requiere Negocio y cómo podemos minimizar este tiempo gracias al DevUI con StencilJS para adecuar una aplicación de Angular a las exigencias del mercado en tiempo record.