Cómo trabajar con elementos personalizados

Introducción

La Web carece de expresión. Para entender lo que quiero decir, echa un vistazo a una aplicación web "moderna" como Gmail:

Gmail

La sopa de <div> no tiene nada moderno. Y, sin embargo, así es como desarrollamos las aplicaciones web. Es triste. ¿No deberíamos exigir más a nuestra plataforma?

Lenguaje de marcado atractivo Hagámoslo una cosa

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 atroz? 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é emocionante! Esta app también tiene sentido. Es significativo, fácil de entender y, lo mejor de todo, es mantenible. En el futuro, sabrás exactamente lo que hace con solo examinar su infraestructura declarativa.

Primeros pasos

Los elementos personalizados permiten a los desarrolladores web definir tipos nuevos de elementos HTML. La especificación es una de las varias primitivas de API nuevas que pertenecen al conjunto de Componentes web, pero posiblemente sea la más importante. Los componentes web no existen sin las funciones desbloqueadas por elementos personalizados:

  1. Definir nuevos elementos HTML/DOM
  2. Crear elementos que se extienden desde otros elementos
  3. Agrupa lógicamente las funciones personalizadas en una sola etiqueta.
  4. Cómo extender la API de elementos del DOM existentes

Registra elementos nuevos

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

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

El primer argumento para 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. Esta restricción permite que el analizador distinga los elementos personalizados de los normales, pero también garantiza la compatibilidad a futuro cuando se agreguen etiquetas nuevas al HTML.

El segundo argumento es un objeto (opcional) que describe la prototype del elemento. Aquí puedes agregar funcionalidades personalizadas (por ejemplo, propiedades y métodos públicos) a tus elementos. Hablaremos sobre este tema más adelante.

De forma predeterminada, los elementos personalizados se 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 enseña 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 para crear instancias de elementos si no deseas usar el constructor.

Extiende elementos

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

Cómo extender elementos nativos

Supongamos que no estás contento con el Joe <button>. Quieres potenciar sus capacidades para que sea un "botón combinado". 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 se heredan de los elementos nativos se denominan elementos personalizados de tipo de extensión. Se heredan de una versión especializada de HTMLElement como una forma de decir: "el elemento X es una Y".

Ejemplo:

<button is="mega-button">

Extiende un elemento personalizado

Para crear un elemento <x-foo-extended> que extienda el elemento personalizado <x-foo>, solo debes heredar su prototipo y decir de qué etiqueta estás heredando:

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

¿Alguna vez te preguntaste por qué el analizador de HTML no se adapta a las etiquetas no estándar? Por ejemplo, es muy útil si declaramos <randomtag> en la página. De acuerdo con la especificación de HTML, haz lo siguiente:

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

No ocurre lo mismo con los elementos personalizados. Los elementos con nombres de elementos personalizados válidos se heredan de HTMLElement. Para verificar esto, activa la consola: 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 sin resolver

Debido a que la secuencia de comandos registra los elementos personalizados 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 sin resolver. Estos son elementos HTML que tienen un nombre de elemento personalizado válido, pero que aún no se registraron.

Esta tabla puede ayudarte a mantener las cosas claras:

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

Crea instancias de elementos

Las técnicas comunes de creación de elementos aún se aplican a los elementos personalizados. Al igual que con cualquier elemento estándar, se pueden declarar en HTML o crearse en DOM con JavaScript.

Creando instancias de etiquetas personalizadas

Declaralas:

<x-foo></x-foo>

Crea 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);

Crea instancias de elementos de extensión de tipo

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

Declaralas:

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

Crea 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 su segundo parámetro.

Usa el operador new:

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

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

Cómo agregar propiedades y métodos de JS

Lo importante de los elementos personalizados es que puedes agrupar la funcionalidad personalizada con el elemento definiendo las propiedades y los métodos en la definición del elemento. Considéralo una manera de crear una API pública para tu elemento.

A continuación, se muestra 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, existen miles de formas de construir un prototype. Si no te gusta crear prototipos como este, aquí hay 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. La segunda permite el uso de get/set.

Métodos de devolución de llamada de Lifecycle

Los elementos pueden definir métodos especiales para aprovechar momentos interesantes de su existencia. Estos métodos reciben el nombre correcto de devoluciones de llamada de ciclo de vida. Cada uno tiene un nombre y 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: Define 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 de ciclo de vida son opcionales, pero defínelas cuando sea necesario. Por ejemplo, supongamos que tu elemento es lo suficientemente complejo y abre una conexión a IndexedDB en createdCallback(). Antes de quitarlo del DOM, realiza el trabajo de limpieza necesario en detachedCallback(). Nota: No debes confiar en esto, por ejemplo, si el usuario cierra la pestaña, pero piénsalo como un posible hook de optimización.

Otra devolución de llamada de ciclo de vida de casos de uso consiste en 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> a fin de asignarle 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 proporcionarle a un elemento algo de 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});

La creación de una instancia de esta etiqueta y la inspección en Herramientas para desarrolladores (hacer clic con el botón derecho y seleccionar Inspect Element) debería mostrar lo siguiente:

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

Encapsulamiento de componentes internos en Shadow DOM

Por sí misma, Shadow DOM es una herramienta potente para encapsular contenido. Úsalo junto con elementos personalizados y todo se vuelve mágico.

Shadow DOM proporciona elementos personalizados:

  1. Una forma de ocultar sus instintos, lo que protege a los usuarios de los detalles de implementación sangrientos.
  2. Encapsulamiento de estilo... gratis.

Crear un elemento a partir de Shadow DOM es como crear uno que renderice el lenguaje de 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é una Shadow Root para <x-foo-shadowdom> y, luego, la llené con lenguaje de marcado. Con la configuración "Show Shadow DOM" habilitada en las Herramientas para desarrolladores, verás una #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 oscura!

Crea elementos a partir de una plantilla

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

Ejemplo: Registrar 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 una gran capacidad. Analicemos todo lo que sucede:

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

¡Muy bien!

Cómo aplicar diseño a elementos personalizados

Al igual que con cualquier etiqueta HTML, los usuarios de tu etiqueta personalizada pueden definir su estilo 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>

Cómo aplicar diseño a elementos que usan Shadow DOM

Cuando incorporas Shadow DOM a la combinación, queda mucho mucho más. Los elementos personalizados que usan Shadow DOM heredan sus grandes beneficios.

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

El estilo de Shadow DOM es un tema muy importante. Si quieres obtener más información al respecto, te recomendamos algunos de mis otros artículos:

Prevención FOUC usando :unresolved

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

Ejemplo: Aplica fundido de entrada en las etiquetas "x-foo" cuando están registradas:

<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 sin resolver, no a los elementos que se 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 asistencia del navegador

Detección de funciones

Para detectar la función, se debe verificar si document.registerElement() existe:

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 función experimental en Chrome 27 y Firefox 23. Sin embargo, la especificación evolucionó bastante desde entonces. Chrome 31 es la primera versión en admitir las especificaciones actualizadas.

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

¿Qué sucedió con HTMLElement?

Para aquellos que siguieron el trabajo de estandarización, sabes que una vez hubo <element>. Era el caso de las abejas. Puedes usarlo para registrar de forma declarativa elementos nuevos:

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

Lamentablemente, hubo demasiados problemas de tiempo con el proceso de actualización, los casos límite y las situaciones similares a Armageddon como para funcionar. Tuvo que archivarse <element>. En agosto de 2013, Dimitri Glazkov realizó una publicación en public-webapps para anunciar su eliminación, al menos por ahora.

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 proporcionan la herramienta para ampliar el vocabulario de HTML, enseñarle nuevos trucos y atravesar los agujeros de gusano de la plataforma web. Combínalos con las otras primitivas de plataforma nuevas, como Shadow DOM y <template>, y comenzaremos a darnos cuenta de los componentes web. Las marcas pueden volver a ser atractivas.

Si te interesa comenzar a usar componentes web, te recomiendo que consultes Polymer. Es más que suficiente para ponerte en marcha.