Introdução ao Shadow DOM

Dominic Cooney
Dominic Cooney

Introdução

Web Components é um conjunto de padrões modernos que:

  1. Possibilitar a criação de widgets
  2. ...e que podem ser reutilizados de forma confiável
  3. E que não quebrarão as páginas se a próxima versão do componente alterar os detalhes de implementação internos.

Isso significa que você precisa decidir quando usar HTML/JavaScript e quando usar Web Components? Não! HTML e JavaScript podem criar elementos visuais interativos. Os widgets são elementos visuais interativos. Faz sentido aproveitar suas habilidades em HTML e JavaScript ao desenvolver um widget. Os padrões do Web Components foram projetados para ajudar você a fazer isso.

No entanto, há um problema fundamental que dificulta o uso de widgets criados com HTML e JavaScript: a árvore do DOM dentro de um widget não é encapsulada do restante da página. Essa falta de encapsulamento significa que a folha de estilo do documento pode ser aplicada acidentalmente a partes dentro do widget, o JavaScript pode modificar acidentalmente partes dentro do widget, os IDs podem se sobrepor aos IDs no widget e assim por diante.

Os componentes da Web são compostos por três partes:

  1. Modelos
  2. Shadow DOM
  3. Elementos personalizados

O Shadow DOM resolve o problema de encapsulamento da árvore do DOM. As quatro partes do Web Components foram projetadas para trabalhar em conjunto, mas você também pode escolher quais partes deles usar. Este tutorial mostra como usar o Shadow DOM.

Olá, Mundo das Sombras

Com o Shadow DOM, os elementos podem receber um novo tipo de nó associado a eles. Esse novo tipo de nó é chamado de raiz paralela. Um elemento que tem uma raiz paralela associada é chamado de host de sombra. O conteúdo de um host sombra não é renderizado, e sim o conteúdo da raiz paralela.

Por exemplo, se você tivesse uma marcação como esta:

<button>Hello, world!</button>
<script>
var host = document.querySelector('button');
var root = host.createShadowRoot();
root.textContent = 'こんにちは、影の世界!';
</script>

então, em vez de

<button id="ex1a">Hello, world!</button>
<script>
function remove(selector) {
  Array.prototype.forEach.call(
      document.querySelectorAll(selector),
      function (node) { node.parentNode.removeChild(node); });
}

if (!HTMLElement.prototype.createShadowRoot) {
  remove('#ex1a');
  document.write('<img src="SS1.png" alt="Screenshot of a button with \'Hello, world!\' on it.">');
}
</script>

aparência da sua página

<button id="ex1b">Hello, world!</button>
<script>
(function () {
  if (!HTMLElement.prototype.createShadowRoot) {
    remove('#ex1b');
    document.write('<img src="SS2.png" alt="Screenshot of a button with \'Hello, shadow world!\' in Japanese on it.">');
    return;
  }
  var host = document.querySelector('#ex1b');
  var root = host.createShadowRoot();
  root.textContent = 'こんにちは、影の世界!';
})();
</script>

Não apenas isso, se o JavaScript na página perguntar qual é o textContent do botão, ele não vai receber "こんんちはOferecer影の世界!", mas "Hello, world!" porque a subárvore do DOM sob a raiz paralela é encapsulada.

Separação de conteúdo da apresentação

Agora veremos como usar o Shadow DOM para separar o conteúdo da apresentação. Vamos supor que temos esta tag de nome:

<style>
.ex2a.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.ex2a .boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.ex2a .name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="ex2a outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Aqui está a marcação. É isso que você escreveria hoje. Ele não usa o Shadow DOM:

<style>
.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

Como a árvore do DOM não tem encapsulamento, toda a estrutura da tag de nome está exposta ao documento. Se outros elementos na página usarem acidentalmente os mesmos nomes de classe para estilos ou scripts, correremos o risco.

Podemos evitar momentos ruins.

Etapa 1: ocultar detalhes da apresentação

Semanticamente, o que importa para nós é que:

  • Ela é uma tag de nome.
  • O nome é “Bob”.

Primeiro, escrevemos uma marcação mais próxima da verdadeira semântica que queremos:

<div id="nameTag">Bob</div>

Em seguida, colocamos todos os estilos e divs usados na apresentação em um elemento <template>:

<div id="nameTag">Bob</div>
<template id="nameTagTemplate">
<span class="unchanged"><style>
.outer {
  border: 2px solid brown;

  … same as above …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div></span>
</template>

Neste ponto, "Bob" é a única coisa renderizada. Como movemos os elementos DOM de apresentação para dentro de um <template>, eles não são renderizados, mas podem ser acessados no JavaScript. Fazemos isso agora para preencher a raiz paralela:

<script>
var shadow = document.querySelector('#nameTag').createShadowRoot();
var template = document.querySelector('#nameTagTemplate');
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

Agora que configuramos uma raiz paralela, a tag de nome é renderizada novamente. Se você clicar com o botão direito do mouse na tag de nome e inspecionar o elemento, vai notar que é uma marcação semântica suave:

<div id="nameTag">Bob</div>

Isso demonstra que, ao usar o Shadow DOM, ocultamos os detalhes da apresentação da tag de nome do documento. Os detalhes da apresentação são encapsulados no Shadow DOM.

Etapa 2: separar o conteúdo da apresentação

Nossa tag de nome agora oculta os detalhes da apresentação da página, mas na verdade não separa a apresentação do conteúdo porque, embora o conteúdo (o nome “Bob”) esteja na página, o nome renderizado é aquele que copiamos para a raiz paralela. Se quisermos alterar o nome na tag de nome, teríamos que fazer isso em dois lugares, e eles podem ficar fora de sincronia.

Os elementos HTML são composições. Por exemplo, é possível colocar um botão dentro de uma tabela. A composição é o que precisamos aqui: a tag de nome precisa ser uma composição do plano de fundo vermelho, do texto "Hi!" e do conteúdo que está na tag de nome.

Você, o autor do componente, define como a composição funciona com o widget usando um novo elemento chamado <content>. Isso cria um ponto de inserção na apresentação do widget, e o ponto de inserção escolhe o conteúdo do host sombra para apresentar nesse ponto.

Se alterarmos a marcação no Shadow DOM para esta:

<span class="unchanged"><template id="nameTagTemplate">
<style>
  …
</style></span>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    <content></content>
  </div>
</div>
<span class="unchanged"></template></span>

Quando a tag de nome é renderizada, o conteúdo do host sombra é projetado no local em que o elemento <content> aparece.

Agora a estrutura do documento está mais simples porque o nome está em apenas um lugar: o documento. Se sua página precisar atualizar o nome do usuário, basta escrever:

document.querySelector('#nameTag').textContent = 'Shellie';

e pronto. A renderização da tag de nome é atualizada automaticamente pelo navegador, porque estamos projetando o conteúdo da tag de nome no lugar com <content>.

<div id="ex2b">

Agora temos separação de conteúdo e apresentação. O conteúdo está no documento, e a apresentação está no Shadow DOM. Eles são automaticamente sincronizados pelo navegador quando chega a hora de renderizar algo.

Etapa 3: lucro

Ao separar conteúdo e apresentação, podemos simplificar o código que manipula o conteúdo. No exemplo da tag de nome, esse código só precisa lidar com uma estrutura simples que contém uma <div> em vez de várias.

Agora, se mudarmos nossa apresentação, não precisamos alterar nenhum código!

Por exemplo, digamos que queremos localizar nossa tag de nome. Ele ainda é uma tag de nome, portanto, o conteúdo semântico no documento não muda:

<div id="nameTag">Bob</div>

O código de configuração da raiz paralela permanece o mesmo. Apenas o que é colocado na raiz paralela muda:

<template id="nameTagTemplate">
<style>
.outer {
  border: 2px solid pink;
  border-radius: 1em;
  background: url(sakura.jpg);
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
  font-family: sans-serif;
  font-weight: bold;
}
.name {
  font-size: 45pt;
  font-weight: normal;
  margin-top: 0.8em;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="name">
    <content></content>
  </div>
  と申します。
</div>
</template>

Essa é uma grande melhoria em relação à situação atual na Web, porque o código de atualização de nome pode depender da estrutura do componente, que é simples e consistente. Seu código de atualização de nome não precisa conhecer a estrutura usada para renderização. Se considerarmos o que é renderizado, o nome aparece em segundo lugar em inglês (depois de "Hi! "Meu nome é"), mas primeiro em japonês (antes de "と申ます”). Essa distinção é semanticamente insignificante do ponto de vista da atualização do nome exibido, então o código de atualização do nome não precisa saber sobre esse detalhe.

Crédito extra: projeção avançada

No exemplo acima, o elemento <content> seleciona todo o conteúdo do host sombra. Ao usar o atributo select, é possível controlar o que um elemento de conteúdo projeta. Você também pode usar vários elementos de conteúdo.

Por exemplo, se você tiver um documento com o seguinte:

<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>

e uma raiz paralela que usa seletores de CSS para selecionar conteúdo específico:

<div style="background: purple; padding: 1em;">
  <div style="color: red;">
    <content **select=".first"**></content>
  </div>
  <div style="color: yellow;">
    <content **select="div"**></content>
  </div>
  <div style="color: blue;">
    <content **select=".email">**</content>
  </div>
</div>

O elemento <div class="email"> é correspondido pelos elementos <content select="div"> e <content select=".email">. Quantas vezes o endereço de e-mail de Bob aparece e em quais cores?

A resposta é que o endereço de e-mail de Beto aparece uma vez e é amarelo.

O motivo é que, como as pessoas que hack no Shadow DOM sabem, construir a árvore do que é realmente renderizado na tela é muito importante. O elemento de conteúdo é o convite que permite o conteúdo do documento na parte de renderização do Shadow DOM nos bastidores. Esses convites são enviados em ordem. Quem recebe um convite depende de para quem ele é enviado (ou seja, o atributo select). O conteúdo, uma vez convidado, sempre aceita o convite (quem não aceita?!) e ele sai. Se um convite subsequente for enviado para esse endereço novamente, bem, ninguém está em casa e ele não vem para sua festa.

No exemplo acima, <div class="email"> corresponde ao seletor div e .email, mas, como o elemento de conteúdo com o seletor div está anteriormente no documento, <div class="email"> vai para a parte amarela e ninguém está disponível para a parte azul. Esse pode ser o motivo pelo qual ela é tão azul. A tristeza adora companhia, então nunca se sabe.

Se algo for convidado para nenhuma parte, ele não será renderizado. Foi o que aconteceu com a mensagem “Hello, world” no primeiro exemplo. Isso é útil quando você quer alcançar uma renderização completamente diferente: escreva o modelo semântico no documento, que é o que pode ser acessado pelos scripts da página, mas o oculte para fins de renderização e o conecte a um modelo de renderização muito diferente no Shadow DOM usando JavaScript.

Por exemplo, o HTML tem um bom seletor de datas. Ao escrever <input type="date">, você recebe uma agenda pop-up organizada. Mas e se você quiser permitir que o usuário escolha um intervalo de datas para as férias na ilha de sobremesas (com redes feitas de Red Vines)? Configure seu documento desta maneira:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

Quando o usuário clica nos dias do calendário, o componente atualiza o estado nas entradas startDate e endDate. Quando o usuário envia o formulário, os valores desses elementos de entrada são enviados.

Por que incluí rótulos no documento se eles não serão renderizados? O motivo é que, se um usuário visualizar o formulário com um navegador que não oferece suporte ao Shadow DOM, o formulário ainda poderá ser usado, mas não tão bonito. O usuário vê algo como:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

Introdução ao Shadow DOM

Esses são os conceitos básicos do Shadow DOM. Você vai passar o Shadow DOM 101. Você pode fazer mais com o Shadow DOM, por exemplo, usar várias sombras em um host sombra ou sombras aninhadas para encapsulamento ou arquitetar sua página usando o Model-Driven Views (MDV) e o Shadow DOM. E os componentes da Web são mais do que apenas o Shadow DOM.

Vamos explicar isso em postagens posteriores.