Web Components: uma introdução

Imagine um projeto web que mostre os dados dos usuários em um componente cartão que será usado em várias páginas no projeto:

Ao invés de copiar e colar este código em vários arquivos HTML diferentes, podemos criar nossa própria tag que renderiza este cartão e encapsula os estilos (CSS) e comportamentos (JavaScript).

Primeiro, criamos o arquivo UserCard.js que conterá o código JavaScript deste componente e criamos uma classe representando este componente:

// arquivo UserCard.js

class UserCard {
}

Até aqui, esta é apenas a declaração de uma classe em JavaScript.

Custom Elements

Como queremos criar uma tag, devemos definí-la como um elemento HTML. Para isto, basta fazer nossa classe implementar a interface HTMLElement:

// arquivo UserCard.js

class UserCard extends HTMLElement {
}

Após isso, colocamos o construtor e chamamos o super() da interface HTMLElement:

// arquivo UserCard.js

class UserCard extends HTMLElement {

    constructor() {
        super();
    }

}

E, por último, precisamos registrar nossa tag no CustomElementRegistry - que está disponível globalmente pela variável customElements e permite registrar um elemento customizado em uma página:

// arquivo UserCard.js

class UserCard extends HTMLElement {

    constructor() {
        super();
    }

}

customElements.define("user-card", UserCard);

O método define() de customElements recebe como parâmetro o nome da tag a ser definida e o objeto que vai encapsular o código necessário para sua construção. O nome da tag exige o caractere "-" (traço). Caso este padrão não seja seguido e o nome da tag seja definido, por exemplo, como usercard, receberemos um DOMException no momento de usar a tag:

Uncaught DOMException: Failed to execute 'define' on 'CustomElementRegistry': "usercard" is not a valid custom element name

Por este motivo manteremos o nome como user-card. E para usar nossa nova tag, devemos importá-la em um arquivo HTML e utilizar com a mesma sintaxe de uma tag comum:

<!-- arquivo index.html -->

<html>
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <h2>Web Components</h2>

        <user-card></user-card>

        <script src="UserCard.js"></script>
    </body>
</html>

Como nossa tag não faz nada até o momento, nada vai aparecer no navegador além da frase "Web Components" ao abrir o arquivo index.html. Todo elemento HTML tem a propriedade innerHTML que corresponde ao seu conteúdo. Para vermos algum resultado, vamos sobrescrever esta propriedade com algum conteúdo - por exemplo, com o nome do usuário do componente cartão que estamos desenvolvendo:

// arquivo UserCard.js

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.innerHTML = "<h2>Fulano de Tal<h2>"
    }

}

customElements.define("user-card", UserCard);

Que vai gerar o resultado:

Templates

Nossa tag customizada, apesar de simples, já funciona como esperado. Agora vamos utilizar e entender um pouco sobre outro recurso bastante utilizado quando trabalhamos com componentes web que são os Templates.

Com templates, é possível definir blocos de códigos reutilizáveis. Apesar de já conseguirmos isso sem eles, os templates apresentam uma forma mais racional de fazer isso.

Suponha que queremos repetir o uso de nosso componente várias vezes na página. Seriam muitas chamadas de this.innerHTML = "<h2>Fulano de Tal</h2>". Ou seja, construiria esse elemento várias vezes, sendo que uma única vez já seria necessário.

Ao invés de adicionarmos o conteúdo com innerHTML toda vez que o objeto é construído, podemos usar templates. Como dito na documentação do MDN Web Docs: O elemento HTML <template> é um mecanismo para encapsular um conteúdo do lado do cliente que não é renderizado quando a página é carregada, mas que pode ser instanciado posteriormente em tempo de execução usando JavaScript.

Portanto, ao criarmos algum conteúdo dentro da tag <template>, este conteúdo não é mostrado imediatamente. Mas pode ser clonado para ser posteriormente renderizado:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `<h2>Fulano de Tal</h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        // código removido
    }
}

customElements.define("user-card", UserCard);

Note que criamos o template fora da classe. Agora será preciso clonar o conteúdo deste template que está disponível pelo atributo content. E para clonar o conteúdo, utilizamos o método cloneNode():

template.content.cloneNode(true)

O método cloneNode() recebe um parâmetro booleano para indicar se os elementos filhos do nó que está sendo clonado devem ser clonados juntos ou não. Vamos definir com o valor true para clonar os filhos também.

Agora devemos pegar este elemento clonado e adicionar em nosso componente através do método appendChild():

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `<h2>Fulano de Tal</h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.appendChild(template.content.cloneNode(true));
    }
}

customElements.define("user-card", UserCard);

Esta técnica reduz o custo de análise do HTML porque o conteúdo do modelo é analisado apenas uma vez pelo DOMParser, enquanto que chamar innerHTML dentro do construtor irá analisar o HTML para cada instância. Isso garante uma melhoria na performance de nosso componente.

Atributos

E se quisermos que cada componente que será renderizado na página tenha um conteúdo diferente? Podemos, como qualquer tag HTML, definir atributos. Por exemplo:

<!-- arquivo index.html -->

<html>
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <h2>Web Components</h2>

        <user-card name="Fulano de Tal"></user-card>
        <user-card name="Ciclano de Tal"></user-card>

        <script src="UserCard.js"></script>
    </body>
</html>

O atributo name é definido por nós e pode ter o nome que considerarmos conveniente. Neste momento, nosso template está com um conteúdo fixo e devemos modificar de acordo com atributo name recebido pela nossa tag.

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `<h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this.querySelector("h2").textContent = this._name;
    }
}

customElements.define("user-card", UserCard);

Como nosso componente é um HTMLElement, podemos usar e abusar de todos os recursos que uma tag comum do HTML possui, como o método getAttribute() para pegar o valor do atributo name que definimos anteriormente. E teremos o resultado:

Shadow DOM

Agora que aprendemos um pouco sobre templates, vamos adicionar um estilo para nosso componente. Primeiro, vamos adicionar um estilo para a tag h2 diretamente no arquivo index.html:

<!-- arquivo index.html -->

<html>
    <head>
        <meta charset="UTF-8">
        <style>
            h2 {
                color: red;
            }
        </style
    </head>
    <body>
        <h2>Web Components</h2>

        <user-card name="Fulano de Tal"></user-card>
        <user-card name="Ciclano de Tal"></user-card>

        <script src="UserCard.js"></script>
    </body>
</html>

E vamos obter o seguinte resultado:

Como todos os elementos da página, inclusive de nosso componente, estão dentro de uma tag h2, todos receberão o estilo global. Mas podemos adicionar um estilo específico para nosso componente, modificando a cor para azul, por exemplo. Podemos adicionar a tag <style> em nosso template:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        h2 {
            color: blue;
        }
    </style>
    <h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this.querySelector("h2").textContent = this._name;
    }
}

customElements.define("user-card", UserCard);

Agora temos dois estilos na página para tag h2, o estilo global dentro do arquivo index.html e o estilo dentro de nosso componente. Qual será que vai ser aplicado em cada caso? Ao renderizar a página, obtemos:

Repare que o estilo de nosso componente foi aplicado também para o conteúdo da tag h2 fora dele. Isso acontece porque o template com o estilo de nosso componente é carregado por último e acaba por sobrescrever o estilo da tag h2 externo.

Você pode argumentar que podemos evitar isso utilizando classes CSS e você está totalmente correto! Mas imagine o cenário de um projeto grande em que cada desenvolvedor é responsável por um componente específico. Há grandes chances de nomes iguais de classes CSS serem utilizadas, e isso pode gerar um grande transtorno.

Para evitar este tipo de conflito que trabalharemos com outro recurso chamado de Shadow DOM. A ideia é encapsular código HTML, CSS e JavaScript de nosso componente para não provocar e/ou sofrer alteração externa.

O Shadow DOM é uma sub árvore do DOM que tem seu próprio escopo e que não faz parte do DOM original, tornando possível construir interfaces modulares sem que entrem em conflito uma com a outra.


fonte da imagem: MDN Web Docs

Como especificado no MDN Web Docs, existem algumas terminologias do Shadow DOM que devemos conhecer:

  • Shadow host: o nó DOM regular ao qual o Shadow DOM está anexado.
  • Shadow tree: a árvore DOM dentro do Shadow DOM.
  • Shadow boundary: o lugar onde termina o Shadow DOM e começa o DOM regular.
  • Shadow root: o nó raiz da Shadow tree.

Dito isto, vamos ver como funciona na prática. Iremos isolar nosso componente dentro de um Shadow DOM. Para isso, precisamos criar o nó raiz Shadow Root dentro de nosso componente - que será o Shadow Host. A classe HTMLElement possui o método attachShadow() que podemos utilizar para abrir e criar uma referência para um Shadow Root.

Um Shadow Root possui dois modos: aberto e fechado. Antes de entrarmos nas diferenças entre esses dois modos, iremos criar nosso Shadow Root no modo aberto para ver como ele funciona. O método attachShadow() exige que passemos o modo como parâmetro:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        h2 {
            color: blue;
        }
    </style>
    <h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.attachShadow({mode: 'open'}); // criando o Shadow Root
        this.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this.querySelector("h2").textContent = this._name;
    }
}

customElements.define("user-card", UserCard);

Após esta mudança, ao renderizar novamente a página, vemos que nosso componente não é renderizado e volta a receber o estilo global definido para a tag h2:

Mas é possível verificar que o Shadow Root foi criado inspecionando a página através da ferramenta DevTools do navegador pela aba Elemets:

Note que o conteúdo do template também foi anexado a tag <user-card> mas não é mostrado já que está fora do Shadow Root. Uma vez aberto o Shadow Root, devemos anexar os conteúdos, como nosso template, dentro dele. Após a chamada do método attachShadow(), uma referência ao objeto Shadow Root aberto é disponibilizado através do atributo shadowRoot:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        h2 {
            color: blue;
        }
    </style>
    <h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.appendChild(template.content.cloneNode(true));  // código modificado
        this._name = this.getAttribute("name");
        this.shadowRoot.querySelector("h2").textContent = this._name; // código modificado
    }
}

customElements.define("user-card", UserCard);

Agora, nosso componente é renderizado como antes porque foi anexado ao Shadow Root, vamos novamente inspecioná-lo pela ferramenta DevTools:

Note que agora o conteúdo está dentro do Shadow Root. E como ele está dentro de uma Shadow Tree separada do DOM original, os estilos globais não afetam nosso componente e o resultado da renderização da página é este:

Este foi um exemplo utilizado para encapsular os estilos. Mas o mesmo vale para eventos que possam ser registrados em nosso componente - como um evento de click que pode afetar muitos elementos de uma página e o Shadow DOM vai garantir o encapsulamento.

Agora que vimos um pouco como o Shadow DOM funciona, vamos entender a diferença entre os modos aberto e fechado. O Shadow Root no modo aberto permite que façamos modificações em sua estrutura utilizando JavaScript. Se quisermos acessar o Shadow Root de nosso componente, basta digitar no console:

document.querySelector("user-card").shadowRoot

Isso nos permite acessar o shadowRoot de nosso componente:

E fazer modificações em seu conteúdo, como modificar o conteúdo da tag h2 de nosso componente:

Repare que o encapsulamento, neste sentido, é quebrado já que podemos modificar sua estrutura através do JavaScript. Para que o encapsulamento seja realmente aplicado é que existe o modo fechado do Shadow DOM. Vamos modificar nosso componente para o modo fechado:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        h2 {
            color: blue;
        }
    </style>
    <h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this.attachShadow({mode: 'closed'});  // modificado para o modo fechado
        this.shadowRoot.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this.shadowRoot.querySelector("h2").textContent = this._name;
    }
}

customElements.define("user-card", UserCard);

Mas, ao fazer isso, nosso componente nem sequer é renderizado:

Isso acontece porque o acesso ao atributo shadowRoot não é mais possível. this.shadowRoot agora vai retornar null e receberemos o seguinte erro no console:

Portanto, não será mais possível acessar o shadowRoot externamente pelo JavaScript:

Só será possível fazê-lo dentro de nosso componente. Para isso, criaremos uma referência para ele e assim será possível manipulá-lo e clonar o template para que seja renderizado na página:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        h2 {
            color: blue;
        }
    </style>
    <h2></h2>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this._shadowRoot.querySelector("h2").textContent = this._name;
    }
}

customElements.define("user-card", UserCard);

Dessa maneira, nosso componente é renderizado como antes:

E o acesso ao shadowRoot, via JavaScript, continua retornando null:

Agora temos nosso componente encapsulado e fechado para modificações externas com JavaScript. É evidente que ainda podemos acessá-lo da seguinte forma:

Mas, seguindo as boas práticas da linguagem, isso deve ser evitado já que indica que este atributo é privado e não deve ser acessado fora da classe UserCard.

Isolando o CSS

Escrever código CSS dentro de um template string não é o ideal. O melhor seria se o código CSS de nosso componente ficasse em um arquivo externo de estilos.

Primeiro, vamos criar o arquivo UserCard.css.

/* arquivo UserCard.css */

h2 {
    color: blue;
}

Em seguida, modificamos nosso componente para utilizar este arquivo CSS - importando o arquivo através da tag <link>:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = ` 
    <link type="text/css" rel="stylesheet" href="UserCard.css"></link>
    <h2></h2>`;

class UserCard extends HTMLElement {
    // código omitido
}

customElements.define("user-card", UserCard);

Também é possível utilizar o recurso de regra atribuída do CSS através do @import:

// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = ` 
    <style>@import url("UserCard.css")</style>
    <h2></h2>`;

class UserCard extends HTMLElement {
    // código omitido
}

customElements.define("user-card", UserCard);

Mas como comentado no MDN Web Docs, o carregamento de um estilo externo feito dessa maneira dentro do ShadowRoot pode causar o temido FOUC (Flash of Unstyled Content) - ou seja, pode ocorrer um flash do conteúdo não estilizado enquanto o CSS é carregado.

Por este motivo, muitos desenvolvedores mantêm o conteúdo dos estilos dentro da tag <style> no template string ao invés de tentar evitar FOUC com código adicional - até o momento não existe uma maneira fácil e rápida de evitar isso.

Por facilidade e para evitar este tipo de problema, optaremos por manter o código de estilos dentro do template string, utilizando a tag <style>.

Finalizando o componente do cartão

Agora que entendemos um pouco sobre componentes, podemos voltar ao nosso objetivo final que era criar o componente de cartão de usuários. Basta refatorarmos o código modificando o template de nosso componente e fazendo os ajustes em seu construtor. O código final ficaria assim:

<!-- arquivo index.html -->

<html>
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <h2>Web Components</h2>

        <user-card name="Fulano de Tal" job="Desenvolvedor de Software" image="user.png"></user-card>

        <script src="UserCard.js"></script>
    </body>
</html>
// arquivo UserCard.js

const template = document.createElement('template');
template.innerHTML = `
    <style>
        .card {
            font-family: Arial;
            border: 1px solid #c5c9d1;
            border-radius: 4%;
            width: 150px;
            height: 60px;
            display: flex;
            color: #5b6069;
            font-size: 12px;
            padding: 10px;
        }

        .card:hover {
            background-color: hsl(0, 0%, 97%);
        }

        .card-image,
        .card-content {
            padding: 5px;
        }

        .user-image {
            width: 45px;
            height: 45px;
        }

        .user-name {
            font-weight: bold;
        }

        .user-job {
            font-style: italic;
            font-size: 10px;
            margin-top: 2px;
        }
    </style>
    <div class="card">
        <div class="card-image">
            <img class="user-image" src="user.png"/>
        </div>
        <div class="card-content">
            <div class="user-name"></div>
            <div class="user-job"></div>
        </div>    
    </div>`;

class UserCard extends HTMLElement {

    constructor() {
        super();
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.appendChild(template.content.cloneNode(true));
        this._name = this.getAttribute("name");
        this._job = this.getAttribute("job");
        this._image = this.getAttribute("image");
        this._shadowRoot.querySelector(".user-name").textContent = this._name;
        this._shadowRoot.querySelector(".user-job").textContent = this._job;
        this._shadowRoot.querySelector(".user-image").src = this._image;
    }
}

customElements.define("user-card", UserCard);

E temos como resultado o componente do cartão do usuário, podendo ser reutilizado em qualquer outra página HTML de nosso projeto:

Conclusão

Web Components (componentes web) possui uma especificação própria. Como descrito no MDN Web Docs, Web Components é uma suíte de diferentes tecnologias que permite a criação de elementos customizados reutilizáveis — com a funcionalidade separada do resto do seu código — e que podem ser utilizados em suas aplicações web.

Para utilizar Web Components não é necessário nenhuma biblioteca adicional ou framework, desde que o navegador implemente as seguintes especificações da Web Api:

  • Custom Elements - permite definir tags customizadas
  • Templates - permite definir blocos de códigos reutilizáveis
  • Shadow DOM - permite encapsular o código do componente em uma árvore separada do DOM

Segundo a documentação, atualmente Web Componentes é suportado por padrão no Firefox (versão 63), Chrome, Opera e Edge (versão 79). O Safari já suporta boa parte delas mas não todas. De todo modo, é possível usar Web Components em qualquer navegador através do Polyfill - que nada mais é do que um pedaço de código (geralmente JavaScript) utilizado para simular os recursos ausentes do navegador o mais próximo possível.

Web Components é ainda um conceito novo quando utilizado em JavaScript nativo. Componentes são muito utilizados por bibliotecas e frameworks como Angular, React e Vue - ferramentas sólidas e muito famosas dentro da comunidade front-end. E Web Components, por ser nativo, pode ser utilizado juntamente com essas ferramentas.

Se considerarmos uma equipe grande, separada em vários times, em que cada time utiliza uma ferramenta diferente para cada parte de um projeto, pode acontecer de existir partes em comum entre elas como uma tela de login - com a mesma estrutura para dar unidade ao projeto. Com Web Components, é possível criar um componente nativo que seja compartilhado entre os times. Ou seja, facilita a interoperabilidade do sistema.

Um artigo interessante que compara Web Components com demais ferramentas, levando em consideração estilos de código, performance e bundle-size, é o All the Ways to Make a Web Component do pessoal do WebComponents.dev. Vale dar uma conferida!

No mais, a ideia deste post era apresentar conceitos básicos sobre Web Components e como construir um simples componente com pouco código. Web Components vai muito além. Em futuros posts desta série pretendo mostrar outros recursos como o ciclo de vida de um componente, registro de eventos, componentes compostos e como podemos gerenciar melhor o estado de seus atributos. Até a próxima!

15