Cómo trabajar con elementos personalizados

Introducción

A la Web le falta mucho en cuanto a expresión. Para ver a qué me refiero, echa un vistazo a una app web “moderna” como Gmail:

Gmail

La sopa de <div> no tiene nada de moderna. Sin embargo, así es como compilamos apps web. Es triste. ¿No deberíamos exigir más a nuestra plataforma?

Lenguaje de marcado atractivo. Hagámoslo

El HTML nos brinda una excelente herramienta para estructurar un documento, pero su vocabulario se limita a los elementos que define el estándar HTML.

¿Qué pasaría si el lenguaje de marcado de Gmail no fuera tan malo? ¿Qué pasaría si fuera hermoso?

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

¡Qué refrescante! Esta app también tiene mucho sentido. Es significativo, fácil de entender y, lo mejor de todo, es mantenible. Yo o tú en el futuro sabremos exactamente qué hace solo con examinar su médula declarativa.

Cómo comenzar

Los elementos personalizados permiten que los desarrolladores web definan nuevos tipos de elementos HTML. La especificación es una de varias primitivas de API nuevas que se incluyen en el paraguas de Web Components, pero es posiblemente la más importante. Los componentes web no existen sin las funciones que desbloquean los elementos personalizados:

  1. Define nuevos elementos HTML o DOM
  2. Crea elementos que se extiendan desde otros elementos
  3. Combinar de forma lógica la funcionalidad personalizada en una sola etiqueta
  4. Cómo extender la API de los elementos DOM existentes

Cómo registrar elementos nuevos

Los elementos personalizados se crean con document.registerElement():

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

El primer argumento de document.registerElement() es el nombre de la etiqueta del elemento. El nombre debe contener un guion (-). Por ejemplo, <x-tags>, <my-element> y <my-awesome-app> son nombres válidos, mientras que <tabs> y <foo_bar> no lo son. Esta restricción permite que el analizador distinga los elementos personalizados de los elementos normales, pero también garantiza la compatibilidad con versiones posteriores cuando se agregan etiquetas nuevas al HTML.

El segundo argumento es un objeto (opcional) que describe el prototype del elemento. Este es el lugar para agregar funcionalidad personalizada (p.ej., propiedades y métodos públicos) a tus elementos. Más información sobre esto más adelante.

De forma predeterminada, los elementos personalizados heredan de HTMLElement. Por lo tanto, el ejemplo anterior es equivalente a lo siguiente:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

Una llamada a document.registerElement('x-foo') le informa al navegador sobre el elemento nuevo y muestra un constructor que puedes usar para crear instancias de <x-foo>. Como alternativa, puedes usar las otras técnicas de creación de instancias de elementos si no quieres usar el constructor.

Cómo extender elementos

Los elementos personalizados te permiten extender los elementos HTML existentes (nativos), así como otros elementos personalizados. Para extender un elemento, debes pasarle a registerElement() el nombre y el prototype del elemento del que se heredará.

Cómo extender elementos nativos

Supongamos que no estás conforme con el Joe normal <button>. Te gustaría mejorar sus capacidades para que sea un "Megabotón". Para extender el elemento <button>, crea un elemento nuevo que herede el prototype de HTMLButtonElement y extends el nombre del elemento. En este caso, "button":

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

Los elementos personalizados que heredan de elementos nativos se denominan elementos personalizados de extensión de tipo. Heredan de una versión especializada de HTMLElement como una forma de decir "el elemento X es un Y".

Ejemplo:

<button is="mega-button">

Cómo extender un elemento personalizado

Para crear un elemento <x-foo-extended> que extienda el elemento personalizado <x-foo>, simplemente hereda su prototipo y di de qué etiqueta heredas:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

Consulta Cómo agregar propiedades y métodos de JS a continuación para obtener más información sobre la creación de prototipos de elementos.

Cómo se actualizan los elementos

¿Te has preguntado por qué el analizador de HTML no arroja errores en las etiquetas no estándar? Por ejemplo, es perfectamente aceptable si declaramos <randomtag> en la página. Según la especificación de HTML:

Lo siento, <randomtag>. No eres estándar y heredas de HTMLUnknownElement.

No ocurre lo mismo con los elementos personalizados. Los elementos con nombres de elementos personalizados válidos heredan de HTMLElement. Para verificar este hecho, inicia Console: Ctrl + Shift + J (o Cmd + Opt + J en Mac) y pega las siguientes líneas de código, que muestran true:

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

Elementos no resueltos

Debido a que los elementos personalizados se registran mediante una secuencia de comandos con document.registerElement(), se pueden declarar o crear antes de que el navegador registre su definición. Por ejemplo, puedes declarar <x-tabs> en la página, pero terminar invocando document.registerElement('x-tabs') mucho más tarde.

Antes de que los elementos se actualicen a su definición, se denominan elementos no resueltos. Estos son elementos HTML que tienen un nombre de elemento personalizado válido, pero que no se registraron.

Esta tabla puede ayudarte a mantener la claridad:

Nombre Hereda de Ejemplos
Elemento no resuelto HTMLElement <x-tabs>, <my-element>
Elemento desconocido HTMLUnknownElement <tabs>, <foo_bar>

Cómo crear instancias de elementos

Las técnicas comunes para crear elementos también se aplican a los elementos personalizados. Al igual que con cualquier elemento estándar, se pueden declarar en HTML o crear en DOM con JavaScript.

Cómo crear instancias de etiquetas personalizadas

Decláralos:

<x-foo></x-foo>

Crea un DOM en JS:

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

Usa el operador new:

var xFoo = new XFoo();
document.body.appendChild(xFoo);

Cómo crear instancias de elementos de extensión de tipo

La creación de instancias de elementos personalizados de tipo de extensión es muy similar a las etiquetas personalizadas.

Decláralos:

<!-- <button> "is a" mega button -->
<button is="mega-button">

Crea un DOM en JS:

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

Como puedes ver, ahora hay una versión sobrecargada de document.createElement() que toma el atributo is="" como segundo parámetro.

Usa el operador new:

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

Hasta ahora, aprendimos a usar document.registerElement() para informarle al navegador sobre una etiqueta nueva, pero no hace mucho. Agreguemos propiedades y métodos.

Cómo agregar propiedades y métodos de JS

La ventaja de los elementos personalizados es que puedes agrupar funciones personalizadas con el elemento definiendo propiedades y métodos en la definición del elemento. Piensa en esto como una forma de crear una API pública para tu elemento.

Este es un ejemplo completo:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

Por supuesto, hay miles de formas de construir un prototype. Si no te gusta crear prototipos como este, aquí tienes una versión más condensada de lo mismo:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

El primer formato permite el uso de Object.defineProperty de ES5. El segundo permite el uso de get/set.

Métodos de devolución de llamada del ciclo de vida

Los elementos pueden definir métodos especiales para aprovechar momentos interesantes de su existencia. Estos métodos se denominan, de manera apropiada, devoluciones de llamada de ciclo de vida. Cada uno tiene un nombre y un propósito específicos:

Nombre de la devolución de llamada Se llama cuando
createdCallback se crea una instancia del elemento
attachedCallback se insertó una instancia en el documento
detachedCallback se quitó una instancia del documento
attributeChangedCallback(attrName, oldVal, newVal) Se agregó, quitó o actualizó un atributo.

Ejemplo: Definición de createdCallback() y attachedCallback() en <x-foo>:

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

Todas las devoluciones de llamada del ciclo de vida son opcionales, pero defínelas si es necesario. Por ejemplo, supongamos que tu elemento es lo suficientemente complejo y abre una conexión a IndexedDB en createdCallback(). Antes de que se quite del DOM, realiza la limpieza necesaria en detachedCallback(). Nota: No debes depender de esto, por ejemplo, si el usuario cierra la pestaña, pero piensa en ello como un posible hook de optimización.

Otro caso de uso de devoluciones de llamada del ciclo de vida es configurar objetos de escucha de eventos predeterminados en el elemento:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

Cómo agregar lenguaje de marcado

Creamos <x-foo>, le asignamos una API de JavaScript, pero está en blanco. ¿Le daremos algo de HTML para renderizar?

Las devoluciones de llamada de ciclo de vida son útiles aquí. En particular, podemos usar createdCallback() para dotar a un elemento de un HTML predeterminado:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

Si creas una instancia de esta etiqueta y la inspeccionas en DevTools (haz clic con el botón derecho y selecciona Inspeccionar elemento), deberías ver lo siguiente:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

Encapsulación de los elementos internos en Shadow DOM

Por sí solo, el Shadow DOM es una herramienta potente para encapsular contenido. Úsalo junto con elementos personalizados y verás cómo se vuelve mágico.

Shadow DOM les brinda a los elementos personalizados las siguientes funciones:

  1. Una forma de ocultar su contenido y, de esta manera, proteger a los usuarios de los detalles sangrientos de la implementación.
  2. Encapsulamiento de estilo… sin costo.

Crear un elemento desde Shadow DOM es como crear uno que renderiza un marcado básico. La diferencia está en createdCallback():

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

En lugar de configurar el .innerHTML del elemento, creé un raíz en sombra para <x-foo-shadowdom> y, luego, lo completé con el marcado. Con el parámetro de configuración "Show Shadow DOM" habilitado en las Herramientas para desarrolladores, verás un #shadow-root que se puede expandir:

<x-foo-shadowdom>
  #shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

Esa es la raíz de la sombra.

Crea elementos a partir de una plantilla

Las plantillas HTML son otra primitiva de API nueva que se adapta perfectamente al mundo de los elementos personalizados.

Ejemplo: Registra un elemento creado a partir de un <template> y Shadow DOM:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

Estas pocas líneas de código tienen mucho poder. Veamos qué sucede:

  1. Registramos un elemento nuevo en HTML: <x-foo-from-template>
  2. El DOM del elemento se creó a partir de un <template>
  3. Los detalles atemorizantes del elemento se ocultan con Shadow DOM.
  4. Shadow DOM le da al elemento el encapsulamiento de estilo (p. ej., p {color: orange;} no vuelve naranja toda la página).

¡Muy bien!

Aplica diseño a elementos personalizados

Al igual que con cualquier etiqueta HTML, los usuarios de tu etiqueta personalizada pueden aplicarle diseño con selectores:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

Aplica diseño a elementos que usan Shadow DOM

El embudo se vuelve mucho más profundo cuando incorporas el DOM sombreado. Los elementos personalizados que usan Shadow DOM heredan sus grandes beneficios.

Shadow DOM impregna un elemento con encapsulamiento de estilo. Los estilos definidos en un Shadow Root no se filtran del host ni se extienden desde la página. En el caso de un elemento personalizado, el elemento en sí es el host. Las propiedades del encapsulamiento de estilo también permiten que los elementos personalizados definan estilos predeterminados para sí mismos.

La aplicación de diseño en Shadow DOM es un tema muy amplio. Si quieres obtener más información, te recomiendo algunos de mis otros artículos:

Prevención de la falsificación de solicitudes de usuario con :unresolved

Para mitigar la FOUC, los elementos personalizados especifican una nueva seudoclase de CSS, :unresolved. Úsala para segmentar elementos no resueltos hasta el punto en el que el navegador invoque tu createdCallback() (consulta los métodos de ciclo de vida). Una vez que eso suceda, el elemento ya no será un elemento sin resolver. El proceso de actualización se completó y el elemento se transformó en su definición.

Ejemplo: atenua las etiquetas "x-foo" cuando se registran:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

Ten en cuenta que :unresolved solo se aplica a los elementos no resueltos, no a los elementos que heredan de HTMLUnknownElement (consulta Cómo se actualizan los elementos).

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

Historial y compatibilidad con navegadores

Detección de atributos

La detección de componentes es cuestión de verificar si existe document.registerElement():

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

Navegadores compatibles

document.registerElement() comenzó a aparecer detrás de una marca en Chrome 27 y Firefox ~23. Sin embargo, la especificación evolucionó bastante desde entonces. Chrome 31 es el primero en tener compatibilidad real con las especificaciones actualizadas.

Hasta que la compatibilidad con navegadores sea excelente, existe un polyfill que usan Polymer de Google y X-Tag de Mozilla.

¿Qué pasó con HTMLElementElement?

Quienes hayan seguido el trabajo de estandarización saben que antes había <element>. Fue lo mejor. Puedes usarlo para registrar elementos nuevos de forma declarativa:

<element name="my-element">
    ...
</element>

Lamentablemente, hubo demasiados problemas de sincronización con el proceso de actualización, casos extremos y situaciones similares al Armagedón para resolverlos. Se tuvo que archivar <element>. En agosto de 2013, Dimitri Glazkov publicó en public-webapps un anuncio sobre su eliminación, al menos por el momento.

Vale la pena señalar que Polymer implementa una forma declarativa de registro de elementos con <polymer-element>. ¿Cómo? Usa document.registerElement('polymer-element') y las técnicas que describí en Cómo crear elementos a partir de una plantilla.

Conclusión

Los elementos personalizados nos brindan la herramienta para extender el vocabulario de HTML, enseñarle nuevos trucos y saltar por los 'agujeros de gusano' de la plataforma web. Combínalos con las otras primitivas de la plataforma nuevas, como Shadow DOM y <template>, y comenzamos a darnos cuenta del panorama de los componentes web. El marcado puede volver a ser atractivo.

Si te interesa comenzar a usar componentes web, te recomiendo que consultes Polymer. Tiene más que suficiente para comenzar.