Trabalhar com elementos personalizados

Introdução

A web carece gravemente de expressão. Para entender o que quero dizer, dê uma olhada em um aplicativo da web "moderno" como o Gmail:

Gmail

Não há nada moderno em relação à sopa <div>. Ainda assim, é assim que criamos aplicativos da web. É triste. Não deveríamos exigir mais da nossa plataforma?

Marcação sexy. Vamos transformá-lo

O HTML é uma excelente ferramenta 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 cruel? E se fosse linda:

<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 refrescante! Esse aplicativo também faz sentido. É significativo, fácil de entender e, o melhor de tudo, é sustentável. No futuro, você saberá exatamente o que ele faz apenas examinando a estrutura declarativa.

Como começar

Os elementos personalizados permitem que os desenvolvedores da Web definam novos tipos de elementos HTML. A especificação é um dos vários novos primitivos de API que levam à estrutura Web Components, mas é possivelmente a mais importante. Os componentes da Web não existem sem os recursos desbloqueados por elementos personalizados:

  1. Definir novos elementos HTML/DOM
  2. Criar elementos que se estendem de outros elementos
  3. Agrupe de maneira lógica a funcionalidade personalizada em uma única tag
  4. Ampliar a API dos elementos DOM existentes

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 para document.registerElement() é o nome da tag do elemento. O nome precisa ter um traço (-). Por exemplo, <x-tags>, <my-element> e <my-awesome-app> são nomes válidos, mas <tabs> e <foo_bar> não são. Essa restrição permite que o analisador diferencie elementos personalizados de elementos regulares, mas também garante compatibilidade com versões futuras quando novas tags são adicionadas ao HTML.

O segundo argumento é um objeto (opcional) que descreve o prototype do elemento. Este é o lugar para adicionar funcionalidades personalizadas (por exemplo, propriedades públicas e métodos) aos seus elementos. Vamos falar sobre isso mais adiante.

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 você pode usar para criar instâncias de <x-foo>. Como alternativa, você pode usar as outras técnicas de instanciação de elementos se não quiser usar o construtor.

Como estender elementos

Com os elementos personalizados, você pode estender elementos HTML existentes (nativos), além de outros elementos personalizados. Para estender um elemento, é necessário transmitir a registerElement() o nome e o prototype do elemento do qual ele será herdado.

Como estender elementos nativos

Digamos que você não está contente com o <button> normal. Você quer melhorar os recursos para ser um "megabotão". Para estender o elemento <button>, crie um novo elemento que herde o prototype de HTMLButtonElement e extends o nome do elemento. Neste caso, "button":

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

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

Exemplo:

<button is="mega-button">

Estender um elemento personalizado

Para criar um elemento <x-foo-extended> que estenda o elemento personalizado <x-foo>, basta herdar o protótipo e dizer 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 de HTML não define um ajuste em tags que não são padrão? Por exemplo, seria perfeitamente feliz 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. Elementos com nomes de elementos personalizados válidos herdam de HTMLElement. Para verificar esse fato, acione o Console: Ctrl + Shift + J (ou Cmd + Opt + J no Mac) e cole as seguintes linhas de código. 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 pelo script usando document.registerElement(), eles podem ser declarados ou criados antes do registro da definição pelo navegador. Por exemplo, é possível declarar <x-tabs> na página, mas acabar invocando document.registerElement('x-tabs') muito mais tarde.

Antes do upgrade dos elementos para sua definição, eles são chamados de elementos não resolvidos. Esses são elementos HTML que têm um nome de elemento personalizado válido, mas que não foram registrados.

Esta tabela pode ajudar a manter as coisas em ordem:

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

Instanciar elementos

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

Como criar instâncias de tags personalizadas

Declare-as:

<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 criação de instâncias de elementos personalizados com estilo de extensão é muito parecida com as tags personalizadas.

Declare-as:

<!-- <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 notar, 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 isso não faz muito. Vamos adicionar propriedades e métodos.

Como adicionar propriedades e métodos do JS

O melhor dos elementos personalizados é que você pode agrupar funcionalidades personalizadas com o elemento definindo propriedades e métodos na definição do elemento. Pense nisso como uma maneira 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 criar um prototype. Se você não gosta de criar protótipos como este, 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 do 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 recebem o nome adequado de callbacks do ciclo de vida. Cada um tem um nome e propósito específicos:

Nome da chamada de retorno Chamada 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:definindo 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 precisam ser definidos se/quando fizerem sentido. Por exemplo, digamos que seu elemento seja suficientemente complexo e abra uma conexão com IndexedDB em createdCallback(). Antes que ele seja removido do DOM, faça o trabalho de limpeza necessário em detachedCallback(). Observação:não dependa disso, por exemplo, se o usuário fechar a guia, mas considere isso como um possível gancho de otimização.

Outros callbacks do ciclo de vida de caso de uso servem 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> com uma API JavaScript, mas ele está em branco. Vamos dar algo HTML para renderizar?

Os callbacks do ciclo de vida são úteis aqui. Particularmente, podemos usar createdCallback() para doar um elemento com 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});

Instanciar essa tag e a inspeção no DevTools (clique com o botão direito do mouse e selecione "Inspecionar elemento") deve mostrar:

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

Como encapsular os componentes internos no Shadow DOM

Sozinho, o Shadow DOM é uma ferramenta eficiente para encapsular conteúdo. Use essa combinação com elementos personalizados e deixe tudo fica ainda mais mágico!

O Shadow DOM oferece elementos personalizados:

  1. Uma forma de esconder o espírito, protegendo os usuários de detalhes de implementação sangrentos.
  2. Encapsulamento de estilo... sem custo financeiro.

Criar um elemento do Shadow DOM é como criar um que renderiza uma 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 paralela para <x-foo-shadowdom> e a preencha com marcação. Com a configuração "Show Shadow DOM" ativada no DevTools, você verá uma #shadow-root que pode ser expandida:

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

Essa é a Raiz das Sombras!

Criar elementos com base em um modelo

Modelos HTML são outro novo primitivo de API que se encaixa perfeitamente no mundo de elementos personalizados.

Exemplo:como registrar um elemento criado usando uma <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 têm muitos impactos. Vamos entender tudo o que está acontecendo:

  1. Registramos um novo elemento em HTML: <x-foo-from-template>
  2. O DOM do elemento foi criado usando uma <template>
  3. Os detalhes assustadores do elemento são ocultados usando o Shadow DOM
  4. O Shadow DOM faz o encapsulamento do estilo do elemento.Por exemplo, p {color: orange;} não torna a página inteira laranja.

Muito bem!

Definir o estilo de elementos personalizados

Assim como acontece com 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>

Como definir o estilo de elementos que usam o Shadow DOM

A toca do coelho vai muito muito mais fundo quando você inclui o Shadow DOM. Os elementos personalizados que usam o Shadow DOM herdam os ótimos benefícios.

O Shadow DOM insere um elemento no encapsulamento de estilo. Os estilos definidos em uma raiz paralela não vazam do host nem vazam da página. No caso de um elemento personalizado, ele é o host. As propriedades do encapsulamento de estilo também permitem que os elementos personalizados definam estilos padrão.

A estilização do Shadow DOM é um assunto muito importante. Se você quiser saber mais sobre ele, 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 direcionar elementos não resolvidos até o ponto em que o navegador invoca o createdCallback(). Consulte Métodos de ciclo de vida. Depois que isso acontece, o elemento deixa de ser um elemento não resolvido. O processo de upgrade foi concluído e o elemento foi transformado na definição dele.

Exemplo: mostrar tags "x-foo" gradualmente quando elas forem registradas:

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

Lembre-se de que :unresolved só se aplica 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 do navegador

Detecção de recursos

Para detectar um recurso, basta 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 aterrissar por trá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 usado pelo Polymer do Google e pela X-Tag do Mozilla.

O que aconteceu com HTMLElementElement?

Para aqueles que seguiram o trabalho de padronização, você sabe que já houve <element>. Foram os joelhos de abelhas. É possível usá-lo para registrar de maneira declarativa novos elementos:

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

Infelizmente, houve muitos problemas de tempo com o processo de upgrade, casos extremos e cenários semelhantes ao Armageddon para resolver tudo. <element> precisou ser armazenado. Em agosto de 2013, Dimitri Glazkov postou no public-webapps anunciando sua remoção, pelo menos por enquanto.

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

Conclusão

Os elementos personalizados oferecem a ferramenta para ampliar o vocabulário do HTML, ensinar novos truques e passar pelos buracos 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 componentes da Web. A marcação pode ficar sexy de novo!

Caso você tenha interesse em começar a usar os componentes da Web, recomendamos conferir o Polymer. Isso é mais do que o suficiente para começar.