Cómo trabajar con elementos personalizados

Introducción

A la Web le falta mucho en cuanto a expresión. Para saber a qué me refiero, echa un vistazo a una aplicación web "moderna" como GMail:

Gmail

La sopa <div> no es moderna. Y, sin embargo, así es como creamos aplicaciones web. Es triste. ¿No deberíamos exigir más de nuestra plataforma?

Lenguaje de marcado sexi. 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é pasa si el lenguaje de marcado para Gmail no es 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 significativa, fácil de entender y, lo mejor de todo, se puede mantener. En el futuro, sabrán exactamente lo que hace con solo examinar su columna declarativa.

Cómo comenzar

Los elementos personalizados permiten a los desarrolladores web definir nuevos tipos de elementos HTML. La especificación es una de varias primitivas nuevas de la API que incluyen componentes web, pero posiblemente sea 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. Agrupa lógicamente funcionalidades personalizadas en una sola etiqueta.
  4. Cómo extender la API de los elementos DOM existentes

Registro de 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 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 normales, pero también garantiza la compatibilidad con versiones posteriores cuando se agregan etiquetas nuevas a HTML.

El segundo argumento es un objeto (opcional) que describe el prototype del elemento. Este es el lugar para agregar funciones personalizadas (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 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 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 pasar a registerElement() el nombre y el prototype del elemento del cual se heredará.

Cómo extender elementos nativos

Supongamos que no estás conforme con el contacto normal Juan <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">

Extiende 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 HTML no genera errores en las etiquetas no estándar? Por ejemplo, se verá muy bien si declaramos <randomtag> en la página. Según la especificación de HTML:

Lo siento, <randomtag>. Eres no 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 sin resolver

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 sin resolver. Son elementos HTML que tienen un nombre de elemento personalizado válido, pero que no se registraron.

Esta tabla puede ayudarte a entender lo siguiente:

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 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 crearse en DOM mediante JavaScript.

Crea instancias de etiquetas personalizadas

Decláralos:

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

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áralas:

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

Usa el operador new:

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

Hasta ahora, aprendimos a usar document.registerElement() para indicarle al navegador que hay 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 manera de crear una API pública para tu elemento.

A continuación, te mostramos 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 ES5 Object.defineProperty. La segunda permite usar get/set.

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

Los elementos pueden definir métodos especiales para aprovechar los interesantes momentos 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 de ciclo de vida son opcionales, pero defínelas si tiene sentido hacerlo. Por ejemplo, supongamos que tu elemento es lo suficientemente complejo y abre una conexión con IndexedDB en createdCallback(). Antes de quitarla del DOM, haz el trabajo de limpieza necesario en detachedCallback(). Nota: No debes depender de esto, por ejemplo, si el usuario cierra la pestaña, pero considéralo como un posible hook de optimización.

Otra devolución de llamada de ciclo de vida de casos de uso sirve para configurar objetos de escucha de eventos predeterminados en el elemento:

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

Agrega lenguaje de marcado

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

Aquí son útiles las devoluciones de llamada del ciclo de vida. En particular, podemos usar createdCallback() para proporcionar HTML predeterminado a un elemento:

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 (haz clic con el botón derecho y selecciona Inspect Element) debería mostrar lo siguiente:

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

Cómo encapsular los componentes internos en Shadow DOM

Por sí mismo, Shadow DOM es una herramienta potente para encapsular contenido. Úsalo junto con elementos personalizados y las cosas se pondrán mágicas.

Shadow DOM les brinda a los elementos personalizados lo siguiente:

  1. Una forma de ocultar sus instintos y, así, proteger a los usuarios de detalles de implementación sangrientos.
  2. Encapsulamiento del estilo... libre.

Crear un elemento a partir de 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é una shadow Root para <x-foo-shadowdom> y, luego, lo llené con lenguaje de 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 shadow.

Crea elementos a partir de una plantilla

Las plantillas HTML son otra primitiva nueva de API que encaja perfectamente en el mundo de los elementos personalizados.

Ejemplo: Se registra un elemento creado a partir de un <template> y un 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 impacto. Veamos qué sucede:

  1. Registramos un nuevo elemento 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 brinda al elemento el encapsulamiento de estilo (p. ej., p {color: orange;} no convierte toda la página en 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 diseñarla mediante 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

Cuando integras Shadow DOM en la mezcla, el enfoque del conejo es mucho mucho más profundo. Los elementos personalizados que usan Shadow DOM heredan sus grandes beneficios.

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

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

Prevención de ataques de falsificación de solicitudes de usuario (FOUC) con :unresolved

Para mitigar 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. Se completó el proceso de actualización 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 sin resolver, no a los elementos heredados 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 del navegador

Detección de funciones

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 la especificación actualizada.

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

¿Qué sucedió con HTMLElementElement?

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

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

Lamentablemente, hubo demasiados problemas de tiempo con el proceso de actualización, los casos de esquina y las situaciones similares a Armageddon 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 proporcionan la herramienta necesaria para ampliar el vocabulario de HTML, enseñarle nuevos trucos y saltar a través de los agujeros de gusano de la plataforma web. Cuando se combinan con las demás primitivas de la nueva plataforma, como Shadow DOM y <template>, se puede comenzar a ver el panorama de Web Components. El marcado puede volver a ser atractivo.

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