Trabalhar com elementos personalizados

Introdução

A Web tem pouca expressão. Para entender o que quero dizer, confira um app da Web "moderno", como o Gmail:

Gmail

Não há nada moderno na sopa de <div>. No entanto, é assim que criamos apps da Web. É triste. Não deveríamos exigir mais da nossa plataforma?

Marcação sexy. Vamos fazer isso acontecer

O HTML é uma ferramenta excelente para estruturar um documento, mas o vocabulário dele é limitado a elementos definidos pelo padrão HTML.

E se a marcação do Gmail não fosse horrível? E se fosse lindo:

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

Que empolgante! Este app também faz sentido. Ele é significativo, fácil de entender e, o melhor de tudo, é manutenção. No futuro, você vai saber exatamente o que ele faz apenas examinando a estrutura declarativa.

Primeiros passos

Os elementos personalizados permitem que os desenvolvedores da Web definam novos tipos de elementos HTML. A especificação é uma das várias primitivas de API novas que estão sendo lançadas sob o guarda-chuva de componentes da Web, mas é provavelmente a mais importante. Os componentes da Web não existem sem os recursos desbloqueados pelos elementos personalizados:

  1. Definir novos elementos HTML/DOM
  2. Criar elementos que se estendem de outros elementos
  3. Agrupar logicamente as funcionalidades personalizadas em uma única tag
  4. Estender a API de elementos DOM atuais

Como registrar novos elementos

Os elementos personalizados são criados usando document.registerElement():

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

O primeiro argumento de document.registerElement() é o nome da tag do elemento. O nome precisa conter um hífen (-). Por exemplo, <x-tags>, <my-element> e <my-awesome-app> são nomes válidos, enquanto <tabs> e <foo_bar> não são. Essa restrição permite que o analisador distingue elementos personalizados de elementos normais, mas também garante a compatibilidade futura quando novas tags são adicionadas ao HTML.

O segundo argumento é um objeto (opcional) que descreve o prototype do elemento. É aqui que você adiciona funcionalidades personalizadas (por exemplo, propriedades e métodos públicos) aos seus elementos. Mais informações sobre isso mais tarde.

Por padrão, os elementos personalizados herdam de HTMLElement. Assim, o exemplo anterior é equivalente a:

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

Uma chamada para document.registerElement('x-foo') ensina o navegador sobre o novo elemento e retorna um construtor que pode ser usado para criar instâncias de <x-foo>. Como alternativa, use as outras técnicas de instanciação de elementos se você não quiser usar o construtor.

Como estender elementos

Os elementos personalizados permitem estender elementos HTML (nativos) e outros elementos personalizados. Para estender um elemento, é necessário transmitir registerElement() o nome e prototype do elemento a ser herdado.

Como estender elementos nativos

Digamos que você não está satisfeito com o <button> do Joe comum. Você quer aumentar os recursos dele para que ele seja um "Mega botão". Para estender o elemento <button>, crie um novo elemento que herda o prototype de HTMLButtonElement e extends o nome do elemento. Nesse caso, "button":

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

Os elementos personalizados que herdam de elementos nativos são chamados de elementos personalizados de extensão de tipo. Eles herdam de uma versão especializada de HTMLElement como uma forma de dizer "o elemento X é um Y".

Exemplo:

<button is="mega-button">

Como estender um elemento personalizado

Para criar um elemento <x-foo-extended> que estenda o elemento personalizado <x-foo>, basta herdar o protótipo e informar de qual tag você está herdando:

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

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

Consulte Como adicionar propriedades e métodos do JS abaixo para mais informações sobre como criar protótipos de elementos.

Como os elementos são atualizados

Você já se perguntou por que o analisador HTML não se adapta a tags não padrão? Por exemplo, ele vai funcionar perfeitamente se declararmos <randomtag> na página. De acordo com a especificação HTML:

Desculpe <randomtag>. Você não é padrão e herda de HTMLUnknownElement.

O mesmo não acontece com elementos personalizados. Os elementos com nomes de elementos personalizados válidos herdam de HTMLElement. Para verificar isso, abra o console: Ctrl + Shift + J (ou Cmd + Opt + J no Mac) e cole as linhas de código a seguir. Elas retornam 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 não resolvidos

Como os elementos personalizados são registrados por script usando document.registerElement(), eles podem ser declarados ou criados antes de a definição ser registrada pelo navegador. Por exemplo, é possível declarar <x-tabs> na página, mas invocar document.registerElement('x-tabs') muito depois.

Antes de serem atualizados para a definição, os elementos são chamados de elementos não resolvidos. Esses são elementos HTML que têm um nome de elemento personalizado válido, mas não foram registrados.

Esta tabela pode ajudar a esclarecer as coisas:

Nome Herda de Exemplos
Elemento não resolvido HTMLElement <x-tabs>, <my-element>
Elemento desconhecido HTMLUnknownElement <tabs>, <foo_bar>

Como instanciar elementos

As técnicas comuns de criação de elementos ainda se aplicam aos elementos personalizados. Como qualquer elemento padrão, eles podem ser declarados em HTML ou criados no DOM usando JavaScript.

Criar instâncias de tags personalizadas

Declare:

<x-foo></x-foo>

Crie o DOM no JS:

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

Use o operador new:

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

Como instanciar elementos de extensão de tipo

A instanciação de elementos personalizados com extensão de tipo é muito semelhante às tags personalizadas.

Declare:

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

Crie o DOM no JS:

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

Como você pode ver, agora há uma versão sobrecarregada de document.createElement() que usa o atributo is="" como segundo parâmetro.

Use o operador new:

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

Até agora, aprendemos a usar document.registerElement() para informar ao navegador sobre uma nova tag, mas ela não faz muito. Vamos adicionar propriedades e métodos.

Como adicionar métodos e propriedades do JS

O elemento personalizado é poderoso porque você pode agrupar a funcionalidade personalizada com o elemento definindo propriedades e métodos na definição do elemento. Pense nisso como uma forma de criar uma API pública para seu elemento.

Confira um exemplo 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);

É claro que existem milhares de maneiras de construir um prototype. Se você não gosta de criar protótipos assim, aqui está uma versão mais condensada da mesma coisa:

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

O primeiro formato permite o uso de Object.defineProperty ES5. A segunda permite o uso de get/set.

Métodos de callback do ciclo de vida

Os elementos podem definir métodos especiais para aproveitar momentos interessantes de sua existência. Esses métodos são chamados de callbacks do ciclo de vida. Cada um tem um nome e uma finalidade específicos:

Nome do callback Chamado quando
createdCallback uma instância do elemento é criada
attachedCallback uma instância foi inserida no documento
detachedCallback uma instância foi removida do documento
attributeChangedCallback(attrName, oldVal, newVal) um atributo foi adicionado, removido ou atualizado

Exemplo:definição de createdCallback() e attachedCallback() em <x-foo>:

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

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

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

Todos os callbacks do ciclo de vida são opcionais, mas defina-os quando e se fizer sentido. Por exemplo, digamos que o elemento seja bastante complexo e abra uma conexão ao IndexedDB em createdCallback(). Antes de ser removido do DOM, faça a limpeza necessária em detachedCallback(). Observação:não confie nesse recurso, por exemplo, se o usuário fechar a guia, mas pense nisso como um possível gancho de otimização.

Outros callbacks de ciclo de vida de caso de uso são usados para configurar listeners de eventos padrão no elemento:

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

Adicionar marcação

Criamos <x-foo> e atribuímos a ele uma API JavaScript, mas ele está em branco. Vamos renderizar algum HTML?

Os callbacks do ciclo de vida são úteis aqui. Em particular, podemos usar createdCallback() para dar a um elemento um HTML padrão:

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

A instanciação dessa tag e a inspeção no DevTools (clique com o botão direito do mouse e selecione Inspect Element) vão mostrar:

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

Encapsular os elementos internos no shadow DOM

Por si só, o Shadow DOM é uma ferramenta poderosa para encapsular conteúdo. Use-o com elementos personalizados e as coisas ficam mágicas.

O Shadow DOM oferece aos elementos personalizados:

  1. Uma maneira de esconder os detalhes da implementação, protegendo os usuários de detalhes sangrentos.
  2. Encapsulamento de estilo… é sem custo financeiro.

Criar um elemento do Shadow DOM é como criar um que renderiza marcação básica. A diferença está em 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});

Em vez de definir o .innerHTML do elemento, criei uma raiz de sombra para <x-foo-shadowdom> e a preenchi com marcação. Com a configuração "Mostrar DOM sombra" ativada nas ferramentas do desenvolvedor, você vai encontrar um #shadow-root que pode ser expandido:

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

Essa é a raiz paralela!

Criar elementos usando um modelo

Os modelos HTML são outra primitiva de API nova que se encaixa bem no mundo dos elementos personalizados.

Exemplo:registrar um elemento criado com um <template> e um 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>

Essas poucas linhas de código fazem muita coisa. Vamos entender tudo o que está acontecendo:

  1. Registramos um novo elemento no HTML: <x-foo-from-template>
  2. O DOM do elemento foi criado com base em um <template>
  3. Os detalhes assustadores do elemento são ocultados usando o Shadow DOM
  4. O Shadow DOM oferece o encapsulamento de estilo do elemento (por exemplo, p {color: orange;} não está tornando toda a página laranja).

Muito bem!

Estilo de elementos personalizados

Assim como qualquer tag HTML, os usuários da sua tag personalizada podem estilizá-la com seletores:

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

Estilo de elementos que usam o shadow DOM

A toca do coelho fica muito mais profunda quando você inclui o shadow DOM. Os elementos personalizados que usam o Shadow DOM herdam os grandes benefícios dele.

O Shadow DOM infunde um elemento com encapsulamento de estilo. Os estilos definidos em uma raiz de sombra não vazam do host e não interferem na página. No caso de um elemento personalizado, o próprio elemento é o host. As propriedades da encapsulação de estilo também permitem que elementos personalizados definam estilos padrão para si mesmos.

O estilo do Shadow DOM é um tópico enorme. Se você quiser saber mais sobre isso, recomendo alguns dos meus outros artigos:

Prevenção de FOUC usando :unresolved

Para reduzir o FOUC, os elementos personalizados especificam uma nova pseudoclasse CSS, :unresolved. Use-o para segmentar elementos não resolvidos, até o ponto em que o navegador invoca o createdCallback() (consulte métodos de ciclo de vida). Depois disso, o elemento não é mais considerado não resolvido. O processo de upgrade é concluído e o elemento é transformado na definição dele.

Exemplo: as tags "x-foo" aparecem gradualmente quando são registradas:

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

:unresolved se aplica apenas a elementos não resolvidos, não a elementos que herdam de HTMLUnknownElement. Consulte Como os elementos são atualizados.

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

Histórico e suporte a navegadores

Detecção de recursos

A detecção de recursos é uma questão de verificar se document.registerElement() existe:

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

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

Suporte ao navegador

O document.registerElement() começou a ser lançado atrás de uma flag no Chrome 27 e no Firefox ~23. No entanto, a especificação evoluiu bastante desde então. O Chrome 31 é o primeiro a ter suporte real para a especificação atualizada.

Até que o suporte do navegador seja excelente, há um polyfill que é usado pelo Polymer do Google e pela X-Tag do Mozilla.

O que aconteceu com HTMLElementElement?

Para quem acompanhou o trabalho de padronização, sabe que já houve <element>. Era a nata. Você pode usá-lo para registrar novos elementos de forma declarativa:

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

Infelizmente, houve muitos problemas de tempo com o processo de upgrade, casos extremos e cenários de Armagedom para resolver tudo. <element> teve que ser arquivado. Em agosto de 2013, Dimitri Glazkov postou no public-webapps anunciando a remoção, pelo menos por enquanto.

Vale a pena observar que o Polymer implementa uma forma declarativa de registro de elemento com <polymer-element>. Como? Ele usa document.registerElement('polymer-element') e as técnicas descritas em Como criar elementos usando um modelo.

Conclusão

Os elementos personalizados nos dão a ferramenta para ampliar o vocabulário do HTML, ensinar novos truques e pular pelos buracos de minhoca da plataforma da Web. Combine-os com outros novos primitivos da plataforma, como Shadow DOM e <template>, para começar a perceber a imagem dos Web Components. A marcação pode ser sexy novamente!

Se você quiser começar a usar os componentes da Web, recomendamos conferir o Polymer. Ele tem mais do que o suficiente para você começar.