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

Datos inmutables en Javascript

Datos inmutables en Javascript

En Javascript todo parece mutable, es decir, que se puede cambiar, pero lo cierto es que también nos ofrece varios mecanismos para conseguir que los datos que manejamos, especialmente los objetos, sean inmutables. Te invitamos a descubrir cómo…

Copiar objetos en Javascript

Copiar objetos en Javascript

Copiar objetos no es algo sencillo, incluso se podría decir que en si mismo no es posible, ya que el concepto «copiar» no entra dentro del paradigma de los objetos. No obstante, por medio de instrucciones como Object.assign() hemos aprendido como obtener objetos con las mismas propiedades, pero está técnica no se puede aplicar a todos los tipos de objetos disponibles en Javascript. Vamos a ver cómo podemos copiar cualquier tipo de objeto…

Descubre los Javascript Array Internals

Descubre los Javascript Array Internals

El Array es una de las estructuras más utilizadas en Javascript y no siempre bien comprendida. Hoy os invitamos a analizar el comportamiento interno de este objeto y descubrir cómo Javascript implementa las diferente acciones con los Array y que operaciones internas se realizan en cada caso.

Web Components: pasado, presente y futuro

Web Components: pasado, presente y futuro

Los Web Components aparecieron en el panorama de desarrollo hace ya bastante tiempo. Desde su presentación se les ha prestado mucha atención, pero lo cierto es que no han sido acogidos de forma generalizada, quizás por la difusión de nuevos y potentes frameworks. Nos preguntamos qué ha pasado con este estándar y, sobre todo, que puede pasar de aquí en adelante con el uso práctico de los componentes web.