Introdução ao Shadow DOM

Dominic Cooney
Dominic Cooney

Introdução

Os componentes da Web são um conjunto de padrões avançados que:

  1. Permitir a criação de widgets
  2. ... que podem ser reutilizados de maneira confiável
  3. …e que não vai quebrar páginas se a próxima versão do componente mudar os detalhes da implementação interna.

Isso significa que você precisa decidir quando usar HTML/JavaScript e quando usar componentes da Web? Não! HTML e JavaScript podem criar coisas visuais interativas. Widgets são elementos visuais interativos. Faz sentido aproveitar suas habilidades em HTML e JavaScript ao desenvolver um widget. Os padrões de componentes da Web foram criados 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 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, seus IDs podem se sobrepor aos IDs dentro do 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 dos Web Components foram projetadas para funcionar em conjunto, mas você também pode escolher quais partes dos Web Components usar. Este tutorial mostra como usar o DOM paralelo.

Hello, Shadow World

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

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

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

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>

como sua página está

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

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

Como separar o conteúdo da apresentação

Agora vamos usar o Shadow DOM para separar o conteúdo da apresentação. Digamos 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>

Confira a marcação. É isso que você vai escrever 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 DOM não tem encapsulamento, toda a estrutura da tag de nome é exposta ao documento. Se outros elementos na página usarem acidentalmente os mesmos nomes de classe para estilização ou script, vamos ter problemas.

Podemos evitar ter um momento ruim.

Etapa 1: ocultar detalhes da apresentação

Semanticamente, provavelmente só nos interessa o seguinte:

  • É uma etiqueta de identificação.
  • O nome é "Bob".

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

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

Em seguida, colocamos todos os estilos e divs usados para a 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 elemento <template>, eles não são renderizados, mas podem ser acessados em 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 de sombra, a tag de nome é renderizada novamente. Se você clicar com o botão direito do mouse na tag de nome e inspecionar o elemento, verá que ele é uma marcação semântica:

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

Isso demonstra que, ao usar o shadow DOM, ocultamos os detalhes de 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 não separa a apresentação do conteúdo, porque, embora o conteúdo (o nome "Bob") esteja na página, o nome renderizado é o que copiamos para a raiz da sombra. Se quisermos alterar o nome na tag de nome, precisaremos fazê-lo em dois lugares, e eles podem ficar fora de sincronia.

Os elementos HTML são compositivos. 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 "Olá!" e do conteúdo que está na tag de nome.

Você, o autor do componente, define como a composição funciona com seu 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 mudarmos 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 de sombra é projetado no local em que o elemento <content> aparece.

Agora a estrutura do documento é mais simples, porque o nome está apenas em um lugar: o documento. Se a 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 a separação de conteúdo e apresentação. O conteúdo está no documento, a apresentação está no shadow DOM. Elas são mantidas automaticamente em sincronia pelo navegador quando chega a hora de renderizar algo.

Etapa 3: lucro

Separando conteúdo e apresentação, podemos simplificar o código que manipula o conteúdo. No exemplo de tag de nome, esse código só precisa lidar com uma estrutura simples contendo um <div> em vez de vários.

Agora, se mudarmos nossa apresentação, não precisamos mudar o código.

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

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

O código de configuração da raiz sombra permanece o mesmo. O que é colocado na raiz de sombra 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>

Isso é uma grande melhoria em relação à situação atual da Web, porque o código de atualização de nome pode depender da estrutura do componente, que é simples e consistente. O código de atualização de nome não precisa saber a estrutura usada para renderização. Se considerarmos o que é renderizado, o nome aparece em segundo lugar em inglês (depois de "Olá! Meu nome é”), mas primeiro em japonês (antes de “と申します”). Essa distinção não tem significado semântico do ponto de vista da atualização do nome que está sendo exibido, portanto, o código de atualização de nome não precisa saber sobre esse detalhe.

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

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

Por exemplo, se você tiver um documento que contém o seguinte:

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

e uma raiz de sombra que usa seletores 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 Bob aparece uma vez e é amarelo.

O motivo é que, como as pessoas que fazem hacks no Shadow DOM sabem, criar a árvore do que é renderizado na tela é como uma grande festa. O elemento de conteúdo é o convite que permite que o conteúdo do documento entre na parte de renderização do shadow DOM. Os convites são entregues em ordem. Quem recebe um convite depende do destinatário, ou seja, do atributo select. O conteúdo, uma vez convidado, sempre aceita o convite (quem não aceitaria?!) e vai em frente. Se um convite subsequente for enviado para esse endereço novamente, ninguém estará em casa, e ele não vai para a sua festa.

No exemplo acima, <div class="email"> corresponde aos seletores div e .email, mas como o elemento de conteúdo com o seletor div vem antes no documento, <div class="email"> vai para a festa amarela, e ninguém está disponível para a festa azul. Pode ser o porquê é tão azul, embora a infelicidade adora companhia, então você nunca sabe.

Se um item for convidado para não partes, ele não será renderizado. Foi o que aconteceu com o texto “Hello, world” no primeiro exemplo. Isso é útil quando você quer alcançar uma renderização radicalmente diferente: escreva o modelo semântico no documento, que é o que é acessível para scripts na página, mas oculte-o para fins de renderização e conecte-o a um modelo de renderização realmente diferente no Shadow DOM usando JavaScript.

Por exemplo, o HTML tem um seletor de datas. Se você escrever para <input type="date">, vai receber um ótimo calendário pop-up. Mas e se você quiser permitir que o usuário escolha um período de datas para as férias na ilha do deserto (com redes feitas de lianas vermelhas). Configure seu documento desta forma:

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

mas cria um shadow DOM que usa uma tabela para criar um calendário elegante que destaca o intervalo de datas e assim por diante. 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 vão ser renderizados? Isso ocorre porque, se um usuário acessar o formulário com um navegador que não oferece suporte ao Shadow DOM, o formulário ainda poderá ser usado, mas não será tão bonito. O usuário recebe uma mensagem como esta:

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

Você entende o básico do shadow DOM

Esses são os conceitos básicos do Shadow DOM. Você passou no teste de introdução ao Shadow DOM! É possível fazer mais com o shadow DOM. Por exemplo, é possível usar várias sombras em um host de sombra ou sombras aninhadas para encapsulamento ou arquitetar sua página usando visualizações orientadas a modelo (MDVs, na sigla em inglês) e shadow DOM. E os componentes da Web são mais do que apenas o Shadow DOM.

Vamos explicar isso em postagens posteriores.