Shadow DOM declarativo

O Shadow DOM declarativo é um recurso padrão da plataforma da Web que tem suporte no Chrome a partir da versão 90. A especificação desse recurso mudou em 2023 (incluindo a renomeação de shadowroot como shadowrootmode), e as versões padronizadas mais atualizadas de todas as partes do recurso foram adicionadas à versão 124 do Chrome.

Compatibilidade com navegadores

  • Chrome: 111
  • Borda: 111.
  • Firefox: 123
  • Safari: 16.4.

Origem

O Shadow DOM é um dos três padrões de componentes da Web, arredondado por modelos HTML e elementos personalizados. O Shadow DOM oferece uma maneira de definir o escopo dos estilos CSS para uma subárvore do DOM específica e isolar essa subárvore do restante do documento. O elemento <slot> oferece uma maneira de controlar onde os filhos de um elemento personalizado precisam ser inseridos na árvore de sombra. A combinação desses recursos permite um sistema de criação de componentes independentes e reutilizáveis que se integram perfeitamente aos aplicativos existentes, como um elemento HTML integrado.

Até agora, a única maneira de usar o Shadow DOM era construindo uma raiz paralela usando JavaScript:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

Uma API imperativa como essa funciona bem para renderização no lado do cliente: os mesmos módulos JavaScript que definem nossos elementos personalizados também criam suas Shadow Roots e definem o conteúdo delas. No entanto, muitos aplicativos da Web precisam renderizar conteúdo no lado do servidor ou em HTML estático no momento da compilação. Isso pode ser importante para proporcionar uma experiência razoável aos visitantes que não são capazes de executar JavaScript.

As justificativas para renderização do lado do servidor (SSR) variam de acordo com o projeto. Alguns sites precisam fornecer HTML renderizado pelo servidor totalmente funcional para atender às diretrizes de acessibilidade, outros optam por oferecer uma experiência sem JavaScript básica como forma de garantir um bom desempenho em dispositivos ou conexões lentas.

Historicamente, era difícil usar o Shadow DOM em combinação com a renderização no servidor porque não havia uma forma integrada de expressar as raízes shadow no HTML gerado pelo servidor. Há também implicações de desempenho ao anexar Shadow Roots a elementos DOM que já foram renderizados sem eles. Isso pode causar a mudança de layout após o carregamento da página ou mostrar temporariamente um flash de conteúdo sem estilo ("FOUC") ao carregar as folhas de estilo da Shadow Root.

O declarative Shadow DOM (DSD, na sigla em inglês) remove essa limitação, levando o Shadow DOM ao servidor.

Como criar uma raiz paralela declarativa

Uma raiz paralela declarativa é um elemento <template> com um atributo shadowrootmode:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

Um elemento de modelo com o atributo shadowrootmode é detectado pelo analisador HTML e aplicado imediatamente como a raiz paralela do elemento pai. Carregar a marcação HTML pura dos resultados de exemplo acima na seguinte árvore do DOM:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

Este exemplo de código segue as convenções do painel Elements do Chrome DevTools para exibir conteúdo do Shadow DOM. Por exemplo, o caractere representa conteúdo do Light DOM com slots.

Isso nos dá os benefícios do encapsulamento e da projeção de slots do Shadow DOM em HTML estático. Nenhum JavaScript é necessário para produzir a árvore inteira, incluindo a raiz paralela.

Hidratação de componentes

O Shadow DOM declarativo pode ser usado sozinho como uma forma de encapsular estilos ou personalizar o posicionamento dos filhos, mas é mais eficiente quando usado com elementos personalizados. Os componentes criados com elementos personalizados são atualizados automaticamente do HTML estático. Com a introdução do Shadow DOM declarativa, agora é possível que um elemento personalizado tenha uma raiz paralela antes do upgrade.

Um elemento personalizado que está sendo atualizado do HTML que inclui uma raiz paralela declarativa já terá essa raiz paralela anexada. Isso significa que o elemento terá uma propriedade shadowRoot já disponível quando for instanciado, sem que seu código crie uma explicitamente. É recomendável verificar se há alguma raiz paralela no this.shadowRoot do construtor do elemento. Se já houver um valor, o HTML desse componente incluirá uma raiz de sombra declarativa. Se o valor for nulo, não havia uma Shadow Root declarativa presente no HTML ou o navegador não oferece suporte ao Shadow DOM declarativa.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

Os elementos personalizados já existem há algum tempo e, até agora, não havia motivo para verificar se há uma raiz paralela antes de criar uma usando attachShadow(). O Shadow DOM declarativo inclui uma pequena mudança que permite que componentes já existentes funcionem, apesar disso: chamar o método attachShadow() em um elemento com uma Shadow Root declarativa não causará um erro. Em vez disso, a raiz sombreada declarativa é esvaziada e retornada. Isso permite que componentes mais antigos não criados para o Shadow DOM declarativo continuem funcionando, já que as raízes declarativas são preservadas até que uma substituição imperativa seja criada.

Para elementos personalizados recém-criados, uma nova propriedade ElementInternals.shadowRoot oferece uma maneira explícita de acessar uma referência à raiz de sombra declarativa de um elemento, aberta ou fechada. Isso pode ser usado para verificar e usar qualquer raiz de sombra declarativa, enquanto ainda retorna para attachShadow() nos casos em que uma não foi fornecida.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

Uma sombra por raiz

Uma raiz paralela declarativa só é associada ao elemento pai. Isso significa que as raízes paralelas são sempre colocadas com o elemento associado a elas. Essa decisão de design garante que as raízes paralelas possam ser transmitidas como o restante de um documento HTML. Também é conveniente para criação e geração, já que a adição de uma raiz paralela a um elemento não exige a manutenção de um registro de raízes paralelas existentes.

A desvantagem de associar raízes paralelas ao elemento pai é que não é possível inicializar vários elementos usando a mesma <template> raiz paralela declarativa. No entanto, isso provavelmente não importa na maioria dos casos em que o Shadow DOM declarativo é usado, já que o conteúdo de cada raiz paralela raramente é idêntico. Embora o HTML renderizado pelo servidor geralmente contenha estruturas de elementos repetidas, seu conteúdo geralmente é diferente, por exemplo, pequenas variações no texto ou nos atributos. Como o conteúdo de uma raiz de sombra declarativa serializada é totalmente estático, o upgrade de vários elementos de uma única raiz de sombra declarativa só funcionaria se os elementos fossem idênticos. Por fim, o impacto de raízes paralelas repetidas semelhantes no tamanho da transferência de rede é relativamente pequeno devido aos efeitos da compactação.

No futuro, talvez seja possível revisitar as raízes paralelas compartilhadas. Se o DOM receber suporte para modelos integrados, as raízes shadow declarativas poderão ser tratadas como modelos instanciados para construir a raiz paralela de um determinado elemento. O projeto declarativo do Shadow DOM permite que essa possibilidade exista no futuro, limitando a associação de raiz paralela a um único elemento.

O streaming é legal

A associação direta das raízes shadow declarativas ao elemento pai simplifica o processo de upgrade e anexação a esse elemento. As raízes de sombra declarativas são detectadas durante a análise do HTML e anexadas imediatamente quando a tag <template> de abertura é encontrada. O HTML analisado no <template> é analisado diretamente na raiz paralela, para que possa ser "transmitido": renderizado à medida que é recebido.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

Somente analisador

O Shadow DOM declarativo é um recurso do analisador HTML. Isso significa que uma raiz paralela declarativa só vai ser analisada e anexada para tags <template> com um atributo shadowrootmode presente durante a análise HTML. Em outras palavras, as raízes de sombra declarativas podem ser construídas durante a análise inicial do HTML:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

A definição do atributo shadowrootmode de um elemento <template> não faz nada, e o modelo continua sendo um elemento de modelo comum:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

Para evitar algumas considerações de segurança importantes, também não é possível criar a Shadow Roots declarativa usando APIs de análise de fragmentos, como innerHTML ou insertAdjacentHTML(). A única maneira de analisar o HTML com as raízes de sombra declarativas aplicadas é usar setHTMLUnsafe() ou parseHTMLUnsafe():

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

Renderização de servidores com estilo

Folhas de estilo inline e externas têm suporte total nas raízes shadow Declarative usando as tags padrão <style> e <link>:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

Os estilos especificados dessa maneira também são altamente otimizados: se a mesma folha de estilo estiver presente em várias raízes sombra declarativas, ela só será carregada e analisada uma vez. O navegador usa uma única CSSStyleSheet de apoio que é compartilhada por todas as raízes paralelas, eliminando a sobrecarga de memória duplicada.

As folhas de estilo construíveis não são compatíveis com o Shadow DOM declarativo. Isso ocorre porque, no momento, não há como serializar folhas de estilo construíveis em HTML e não há como se referir a elas ao preencher adoptedStyleSheets.

Como evitar o relâmpago de conteúdo sem estilo

Um possível problema em navegadores que ainda não são compatíveis com o Shadow DOM declarativo é evitar o "flash de conteúdo sem estilo" (FOUC), em que o conteúdo bruto é mostrado para elementos personalizados que ainda não foram atualizados. Antes do Shadow DOM declarativo, uma técnica comum para evitar o FOUC era aplicar uma regra de estilo display:none aos elementos personalizados que ainda não foram carregados, já que a raiz paralela deles não era anexada e preenchida. Dessa forma, o conteúdo não é exibido até que esteja "pronto":

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

Com a introdução do Shadow DOM declarativa, os elementos personalizados podem ser renderizados ou criados em HTML de modo que o conteúdo de sombra esteja no local e pronto antes que a implementação do componente do lado do cliente seja carregada:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

Nesse caso, o display:none "FOUC" impede que o conteúdo da raiz paralela declarativa apareça. No entanto, a remoção dessa regra faria com que os navegadores sem suporte declarativo do Shadow DOM mostrassem conteúdo incorreto ou sem estilo até que o polyfill declarativo do Shadow DOM fosse carregado e convertia o modelo raiz sombra em uma raiz paralela real.

Felizmente, isso pode ser resolvido no CSS modificando a regra de estilo FOUC. Em navegadores compatíveis com o Shadow DOM declarativo, o elemento <template shadowrootmode> é imediatamente convertido em uma raiz paralela, sem deixar nenhum elemento <template> na árvore do DOM. Os navegadores sem suporte ao Shadow DOM declarativo preservam o elemento <template>, que pode ser usado para evitar o FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

Em vez de esconder o elemento personalizado ainda não definido, o "FOUC" revisado oculta os filhos quando eles seguem um elemento <template shadowrootmode>. Depois que o elemento personalizado for definido, a regra não corresponderá mais. A regra é ignorada em navegadores compatíveis com o Shadow DOM declarativo porque o filho <template shadowrootmode> é removido durante a análise do HTML.

Detecção de recursos e compatibilidade com navegadores

O Shadow DOM declarativo está disponível desde o Chrome 90 e o Edge 91, mas usava um atributo não padrão mais antigo chamado shadowroot em vez do atributo shadowrootmode padronizado. O atributo shadowrootmode e o comportamento de streaming mais recentes estão disponíveis no Chrome 111 e no Edge 111.

Como uma nova API de plataforma da Web, o shadow DOM declarativo ainda não tem suporte generalizado em todos os navegadores. O suporte a navegadores pode ser detectado verificando a existência de uma propriedade shadowRootMode no protótipo de HTMLTemplateElement:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

Polyfill

A criação de um polyfill simplificado para o Shadow DOM declarativo é relativamente simples, já que um polyfill não precisa replicar perfeitamente a semântica de tempo ou as características somente de analisador com as quais a implementação de um navegador se preocupa. Para usar o polyfill declarativo do Shadow DOM, podemos verificar o DOM para encontrar todos os elementos <template shadowrootmode> e convertê-los em Shadow Roots anexadas ao elemento pai. Esse processo poderá ser feito quando o documento estiver pronto ou ser acionado por eventos mais específicos, como ciclos de vida de elementos personalizados.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

Leitura adicional