🧩 Web Components: principais conceitos e como implementá-los
Custom elements allow web developers to define new HTML tags, extend existing ones, and create reusable web components.
Os Web Components foram criados para dar a possibilidade para que os desenvolvedores criem também as suas próprias tags HTML, de responsábilidade única e encapsulados, podendo ser reutilizados ao longo de várias páginas de um site. Estes componentes podem extender funcionalidades das tags nativas de HTML, além de também poderem ser extendidos para a criação de outros componentes mais específicos. Quem conheçe tecnologias como o ReactJS por exemplo, vai notar uma certa familiaridade.
Como dar um nome
- Possuir somente letras minúsculas;
- Deve iniciar com uma letra;
- Possuir no mínimo um hífen ("-");
- Os únicos caracteres especiais possíveis são "-" e "_";
Como utilizar
- Devem ser usados com tag de abertura e fechamento (não podem ser autocontidas, como a tag
<img />, por exemplo);
Como implementar
- O componente deve ser uma classe registrada em
window.customElements; - Registrar a mesma tag duas vezes vai gerar uma DOMException.
- O primeiro comando dentro do construtor é sempre
super();
Hello World
Sabedores das regras, vamos criar, e utilizar o nosso primeiro web component. Verifique sempre no browser, e no devtools o resultado.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<hello-world></hello-world>
<script>
class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<h1>Hello World</h1>`;
}
}
window.customElements.define('hello-world', HelloWorld);
</script>
</body>
</html>
Extendendo de outro componente (Hello User)
Agora vamos criar a classe HelloUser, que extende de HelloWorld, e adiciona o comportamento de receber o parâmetro name.
Se não receber parâmetro, exibirá Hello World pois este é o comportamento implementado no construtor da classe pai.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<hello-world></hello-world>
<hello-user></hello-user>
<hello-user name="Belclei"></hello-user>
<script>
class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<h1>Hello World</h1>`;
}
}
class HelloUser extends HelloWorld {
static get observedAttributes() {
return ['name']
}
attributeChangedCallback(name, oldValue, newValue) {
if (newValue) {
this.innerHTML = `<h1>Hello, ${newValue}</h1>`;
return
}
this.innerHTML = '<h1>Hello World!</h1>'
}
}
window.customElements.define('hello-user', HelloUser);
window.customElements.define('hello-world', HelloWorld);
</script>
</body>
</html>
O método estático observedAttributes sempre retorna um array com o nome dos atributos cuja alteração de valores será observada.
Já attributeChangedCallback é o callback que será disparado quando um dos atributos observados for alterado (nosso componente já começa a ficar reativo).
Expondo atributos
Para que possamos acessar diretamente os parâmetros via javascript, como seria o caso do atributo id (meuComponente.id), precisamos implementar setters e getters. No exemplo abaixo, além de separar arquivos HTML e JS, implementaremos o componente Hello User Desabilitável. Confira:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<hello-world></hello-world>
<hello-user></hello-user>
<hello-user name="Belclei"></hello-user>
<hello-user-disableable name="Fulano" disabled></hello-user-disableable>
<script src="./js/hello-world.js"> </script>
<script src="./js/hello-user.js"> </script>
<script src="./js/hello-user-disableable.js"> </script>
</body>
</html>
class HelloUserDisableable extends HelloUser {
static get observedAttributes() {
return ['disabled', 'name'];
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'disabled') {
if (this.disabled) {
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', 'true');
this.style.color = '#DDD'
} else {
this.setAttribute('tabindex', '0');
this.setAttribute('aria-disabled', 'false');
this.style.color = ''
}
}
if (name === 'name') {
if (newValue) {
this.innerHTML = `<h1>Hello, ${newValue}</h1>`;
return
}
this.innerHTML = '<h1>Hello World!</h1>';
}
}
}
window.customElements.define('hello-user-disableable', HelloUserDisableable);
Perceba que criei o componente extendendo de HelloUser. Mas também poderia ser de HelloWorld, já que todos os métodos foram sobrescritos, com excessão do construtor.
Os métodos gete set são chamados sempre que o atributo é referenciado diretamente no JS (document.querySelector('hello-user-disableable').disabled), mas não quando sobre ele são executadas as funções getAttribute, ou removeAttribute, por exemplo.
Vamos agora criar um botão
Imagine que precisássemos criar um botão, e tivéssemos que reimplementar todo o comportamento que já existe em um botão nativo. Ainda bem que dá para extender um componente nativo. Infelizmente, até o momento, somente o Chrome já terminou de implementar esta funcionalidade .
class LogButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', e => console.log('button clicked'));
}
}
customElements.define('log-button', LogButton, { extends: 'button' });
<button is="log-button">Clique Aqui</button>
Repare em dois detalhes. O primeiro é sobre o uso do terceiro parâmetro do customElements.define, que é um objeto que recebe a opção extends em que inserimos qual a tag que desejamos estender, e o segundo que não colocamos e sim usando o atributo is direto na tag button.
Métodos de ciclo de vida
constructor: chamado quando um novo elemento é instanciado ou aprimorado. Útil para definir estados iniciais, adicionar eventos e afins.connectedCallback: chamado quando o elemento é inserido dentro do DOM. Útil para rodar código preparatório ou que receba alguma condição externa ao entrar no DOM.disconnectedCallback: chamado quando o elemento é removido do DOM. Útil para rodar códigos de limpeza, como remoção de ouvintes de evento, e cancelamento de chamadas rest.attributeChangedCallback(attrName, oldVal, newVal): chamando quando algum atributo observado é adicionado, alterado ou removido. Esses atributos são listados no array retornado no método getter estáticoobservedAttributes. Esse é o único método que recebe parâmetros, sendo o primeiro o nome do atributo alterado, o segundo sendo o valor antigo do atributo e o último sendo o novo valor.adoptedCallback: chamado quando o elemento é movido de documento. (por exemplo, quando alguém invocadocument.adoptNode(el)). Somente encontrei um exemplo de implementação na internet, onde um elemento da DOM dentro de um iframe era transportada para a DOM da página principal.
Shadow DOM
Embora tenhamos criado nosso componente, ele não está isolado, ou imune a interferências externas. CSS ou Javascript podem alterar seu comportamento. O Shadow DOM foi criado para ser uma DOM separada, que pode ser anexada a um componente, resolvendo este problema de encapsulamento. A shadow DOM possui dois modos de funcionamento:
open: O JavaScript na página externa pode acessar o Shadow DOM (usandoelement.shadowRoot);closed: o Shadow DOM só pode ser acessado dentro do Componente Web.
Vamos agora introduzir um novo componente para começar a aprender sobre essa separação, o shadow-hello. Seu conteúdo será uma cópia do componente hello-world. Primeiro vamos adicionar uma estilização no elemento <h1> e ver o que acontece:
this.innerHTML = `<style>
h1 {
text-align: center;
padding: 1em;
margin: 0 0 2em 0;
background-color: #eee;
border: 1px solid #666;
}
</style>
<h1>Hello World</h1>`;
Agora todos os elementos <h1> presentes na página estarão com a mesma estilização. Para evitar isso, vamos reimplementar o componente adicionando-o ao Shadow DOM:
class ShadowHello extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
h1 {
text-align: center;
padding: 1em;
margin: 0 0 2em 0;
background-color: #eee;
border: 1px solid #666;
}
</style>
<h1>Hello World!</h1>`;
}
}
window.customElements.define('shadow-hello', ShadowHello);
Perceba que agora somente o componente shadow-hello possui a estilização. Ele não pode ser modificado por JavaScript ou CSS fora do componente, embora alguns estilos como a fonte e a cor sejam herdados da página porque não foram explicitamente definidos. E se passarmos a estilização para o HTML? Agora somente o shadow-hello NÃO é estilizado.
Você opode utilizar o seletor CSS :host para estilizar o elemento externo <shadow-hello> de dentro do Componente Web:
:host {
color: red;
}
Você também pode definir estilos a serem aplicados quando o elemento usa uma classe específica, por exemplo <shadow-hello class="red">:
:host(.red) {
color: red;
}
E se fosse necessário alterar o conteúdo interno de shadow-hello via JS? Como o componente foi criado com o modo closed isso é impossível.
const shadow = this.attachShadow({ mode: 'closed' });
Alterando o modo para open, você pode alterar o conteúdo interno do componente desta forma:
document.querySelector('shadow-hello').shadowRoot.innerHTML = '<h1>Novo conteúdo</h1';
Templates
A definição de HTML dentro do javascript pode tornar-se impraticável para componentes Web mais complexos, pois é tratado como string. A utilização de um template dá a possibilidade de definir um trecho de HTML na sua página que o seu web component pode usar. Isto tem vários benefícios:
- Você pode ajustar o código HTML sem ter que reescrever as strings dentro do seu JavaScript.
- Os componentes podem ser personalizados sem ter que criar classes JavaScript separadas para cada tipo.
- É mais fácil definir HTML em HTML – e pode ser modificado no servidor ou cliente antes que o componente seja renderizado.
Neste exemplo implementamos o componente message-box, que utiliza o template com id box para colocar o conteúdo de seu innerHTML em um elemento com id message:
class MessageBox extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
const template = document.getElementById('box').content.cloneNode(true);
template.querySelector('#message').innerHTML = this.innerHTML;
shadow.append(template)
}
}
window.customElements.define('message-box', MessageBox);
Os templates são definidos em um <template>, e é prático atribuir um ID para que você possa referenciá-lo dentro da classe do componente. Quando interpreta o HTML, o browser desconsidera os templates, e então, na execução do javascript seu conteúdo é adicionado na DOM. Perceba que o conteúdo de template só é exibido entre o título e o conteúdo da página:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Example</title>
</head>
<body>
<template id="box">
<style>
div {
text-align: center;
font-weight: normal;
padding: 1em;
margin: 0 0 2em 0;
background-color: #eee;
border: 1px solid #666;
}
</style>
<div>
<span id="message"></span>
</div>
</template>
<h1>Título</h1>
<message-box>Texto da mensagem</message-box>
<div>
<p>Conteúdo da página.</p>
</div>
<script src="./message-box.js"></script>
</body>
</html>
A classe Web Component pode acessar este modelo, obter seu conteúdo e clonar os elementos para garantir que você esteja criando um fragmento único de DOM em todos os lugares onde ele é usado:
const template = document.getElementById('message-box').content.cloneNode(true);
Slots e Contents
As tags slot são espaços reservados durante a implementação de um componente, que serão ocupados posteriormente durante a sua utilização. O componente anterior foi alterado para não sobrescrever mais o innerHTML de nenhum componente.
class MessageBox extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
const template = document.getElementById('box').content.cloneNode(true);
shadow.append(template)
}
}
window.customElements.define('message-box', MessageBox);
No template foram adicionados dois slots, um para receber o conteúdo de message e outro para o title. Durante o uso do componente, no HTML, basta utilizar o nome do slot na propriedade slot:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Example</title>
</head>
<body>
<template id="box">
<style>
div {
text-align: center;
font-weight: normal;
padding: 1em;
margin: 0 0 2em 0;
background-color: #eee;
border: 1px solid #666;
}
slot[name="title"] {
color: red;
}
</style>
<div>
<slot name="title">Alert</slot>
<slot name="message"></slot>
</div>
</template>
<h1 slot="teste">Título</h1>
<message-box>
<strong slot="message">Texto da mensagem</strong>
<h1 slot="title">Info</h1>
</message-box>
<div>
<p>Conteúdo da página.</p>
</div>
<script src="./message-box.js"></script>
</body>
</html>
Perceba que o conteúdo original do slot "title" é "Alert". Esse é o valor padrão do slot. Caso ele não seja informado, este será o conteúdo exibido.