Shadow DOM declarativo

O shadow DOM declarativo é um recurso padrão da plataforma da Web, que tem suporte no Chrome desde a versão 90. A especificação desse recurso mudou em 2023, incluindo uma renomeação de shadowroot para shadowrootmode. As versões padronizadas mais atualizadas de todas as partes do recurso foram lançadas na versão 124 do Chrome.

Compatibilidade com navegadores

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

Origem

O Shadow DOM é um dos três padrões de Web Components, complementado por modelos HTML e elementos personalizados. O Shadow DOM oferece uma maneira de definir estilos CSS em uma subárvore específica do DOM e isolar essa subárvore do restante do documento. O elemento <slot> oferece uma maneira de controlar onde os filhos de um elemento personalizado devem ser inseridos na árvore de sombra. Esses recursos combinados permitem que um sistema crie componentes independentes e reutilizáveis que se integram perfeitamente aos aplicativos existentes, assim como um elemento HTML integrado.

Até agora, a única maneira de usar o shadow DOM era construir uma raiz shadow 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 do lado do cliente: os mesmos módulos JavaScript que definem nossos elementos personalizados também criam as raízes sombra e definem o conteúdo delas. No entanto, muitos aplicativos da Web precisam renderizar conteúdo no servidor ou em HTML estático no momento da criação. Isso pode ser importante para oferecer uma experiência razoável a visitantes que talvez não consigam executar JavaScript.

As justificativas para a renderização do lado do servidor (SSR) variam de projeto para projeto. Alguns sites precisam fornecer HTML renderizado pelo servidor totalmente funcional para atender às diretrizes de acessibilidade. Outros escolhem oferecer uma experiência de referência sem JavaScript como forma de garantir um bom desempenho em conexões ou dispositivos lentos.

Historicamente, era difícil usar o shadow DOM em combinação com a renderização do lado do servidor porque não havia uma maneira integrada de expressar raízes paralelas no HTML gerado pelo servidor. Também há implicações de desempenho ao anexar raízes paralelas a elementos DOM que já foram renderizados sem elas. Isso pode causar mudanças no layout depois que a página é carregada ou mostrar temporariamente um flash de conteúdo sem estilo ("FOUC") durante o carregamento das folhas de estilos da raiz da sombra.

O DOM paralelo declarativo (DSD, na sigla em inglês) remove essa limitação, trazendo o DOM paralelo para o servidor.

Como criar uma raiz de sombra 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 de HTML e aplicado imediatamente como a raiz paralela do elemento pai. O carregamento da marcação HTML pura do exemplo acima resulta na seguinte árvore 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 mostrar o conteúdo do shadow DOM. Por exemplo, o caractere representa o conteúdo do light DOM com slots.

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

Hidratação de componentes

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

Um elemento personalizado que está sendo atualizado do HTML que inclui uma raiz shadow declarativa já terá essa raiz anexada. Isso significa que o elemento terá uma propriedade shadowRoot já disponível quando for instanciado, sem que o código crie uma explicitamente. É melhor verificar this.shadowRoot para qualquer raiz de sombra existente no construtor do elemento. Se já houver um valor, o HTML desse componente vai incluir uma raiz de sombra declarativa. Se o valor for nulo, não haverá uma raiz de sombra declarativa presente no HTML ou o navegador não terá suporte ao DOM de sombra 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 existem há algum tempo, e até agora não havia motivo para verificar se uma raiz de sombra já existia antes de criar uma usando attachShadow(). O DOM paralelo declarativo inclui uma pequena mudança que permite que os componentes atuais funcionem apesar disso: chamar o método attachShadow() em um elemento com uma Declarative não gera um erro. Em vez disso, a raiz paralela declarativa é esvaziada e retornada. Isso permite que componentes mais antigos que não foram criados para o DOM paralelo 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 receber uma referência ao raiz de sombra declarativa de um elemento, aberta e fechada. Isso pode ser usado para verificar e usar qualquer raiz de sombra declarativa, retornando a attachShadow() nos casos em que nenhuma 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

Um elemento raiz de sombra declarativo só é associado ao elemento pai. Isso significa que as raízes de sombra são sempre colocalizadas com o elemento associado. Essa decisão de design garante que as raízes de sombra sejam transmitidas por streaming, como o restante de um documento HTML. Também é conveniente para criação e geração, já que adicionar uma raiz sombra a um elemento não exige a manutenção de um registro de raízes sombras existentes.

A desvantagem de associar raízes de sombra ao elemento pai é que não é possível inicializar vários elementos usando a mesma <template> de raiz de sombra declarativa. No entanto, isso provavelmente não importa na maioria dos casos em que o DOM paralelo 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, o conteúdo delas 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, a atualização 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 de sombra semelhantes repetidas 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 de sombra compartilhadas. Se o DOM ganhar suporte a modelagem integrada, as raízes de sombra declarativas poderão ser tratadas como modelos instanciados para construir a raiz de sombra de um determinado elemento. O design atual do shadow DOM declarativo permite que essa possibilidade exista no futuro, limitando a associação da raiz shadow a um único elemento.

O streaming é legal

Associar raízes paralelas declarativas diretamente ao elemento pai simplifica o processo de atualização e anexação a esse elemento. As raízes de sombra declarativas são detectadas durante a análise de 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 "realizado streaming": renderizado conforme 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 de HTML. Isso significa que um shadow root declarativo só será analisado e anexado a 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 de HTML:

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

Definir o 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, as raízes de sombra declarativas também não podem ser criadas usando APIs de análise de fragmentos, como innerHTML ou insertAdjacentHTML(). A única maneira de analisar HTML com 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 do servidor com estilo

As folhas de estilo inline e externas têm suporte total nas raízes de sombra declarativas 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 de sombra declarativas, ela será carregada e analisada apenas uma vez. O navegador usa uma única CSSStyleSheet de suporte compartilhada por todas as raízes de sombra, eliminando a sobrecarga de memória duplicada.

As folhas de estilo constructible não têm suporte no DOM sombra 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 flash de conteúdo sem estilo

Um possível problema em navegadores que ainda não oferecem suporte ao DOM Shadow declarativo é evitar o "flash de conteúdo sem estilo" (FOUC, na sigla em inglês), 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 a FOUC era aplicar uma regra de estilo display:none a elementos personalizados que ainda não foram carregados, já que eles não tinham a raiz shadow anexada e preenchida. Dessa forma, o conteúdo só é exibido quando está "pronto":

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

Com a introdução do DOM de sombra declarativo, os elementos personalizados podem ser renderizados ou criados em HTML para que o conteúdo de sombra esteja no lugar 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, a regra display:none "FOUC" impediria a exibição do conteúdo da raiz de sombra declarativa. No entanto, a remoção dessa regra faria com que os navegadores sem suporte ao DOM paralelo declarativo mostrassem conteúdo incorreto ou sem estilo até que o polyfill do DOM paralelo declarativo carregasse e convertesse o modelo de raiz em uma raiz sombra 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> é convertido imediatamente em uma raiz shadow, sem deixar nenhum elemento <template> na árvore do DOM. Os navegadores que não oferecem suporte ao DOM paralelo 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 ocultar o elemento personalizado ainda não definido, a regra "FOUC" revisada oculta os filhos quando eles seguem um elemento <template shadowrootmode>. Depois que o elemento personalizado é definido, a regra deixa de corresponder. A regra é ignorada em navegadores compatíveis com o shadow DOM declarativo porque o filho <template shadowrootmode> é removido durante a análise de HTML.

Detecção de recursos e suporte a 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 novo atributo shadowrootmode e o comportamento de streaming estão disponíveis no Chrome 111 e no Edge 111.

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

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

Polyfill

Criar 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 exclusivas do parser com que uma implementação de navegador se preocupa. Para usar o polifilador no DOM sombra declarativo, podemos verificar o DOM para encontrar todos os elementos <template shadowrootmode> e, em seguida, convertê-los em raízes de sombra anexadas no elemento pai. Esse processo pode 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