Shadow DOM declarativo

Uma nova maneira de implementar e usar o Shadow DOM diretamente em HTML.

O Shadow DOM declarativo é um recurso de plataforma da Web atualmente no processo de padronização. Ele é ativado por padrão na versão 111 do Chrome.

O Shadow DOM é um dos três padrões dos componentes da Web, arredondados por modelos HTML e elementos personalizados. O Shadow DOM oferece uma maneira de definir o escopo de 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 sombra. Esses recursos combinados permitem que um sistema crie componentes autônomos 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 criar 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 as raízes de sombra e definem o conteúdo. No entanto, muitos aplicativos da Web precisam renderizar conteúdo no lado do servidor ou em HTML estático no momento da criação. Isso pode ser importante para proporcionar uma experiência razoável para visitantes que não conseguem executar o JavaScript.

As justificativas para a renderização do servidor (SSR, na sigla em inglês) variam de acordo com o projeto. Alguns sites precisam fornecer HTML renderizado pelo servidor totalmente funcional para atender às diretrizes de acessibilidade, enquanto outros optam por oferecer uma experiência sem JavaScript de referência 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 as raízes das sombras 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 essas origens. Isso pode causar a mudança do 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 raiz da sombra.

O Shadow DOM declarativo (DSD, na sigla em inglês) remove essa limitação, trazendo o Shadow DOM para o 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 imediatamente aplicado como a raiz paralela do elemento pai. Carregar a marcação HTML puro do exemplo acima resulta 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 Elementos do Chrome DevTools para exibir conteúdo do Shadow DOM. Por exemplo, o caractere ↳ representa conteú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 toda a árvore, incluindo a raiz paralela.

Hidratação de componentes

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

Um elemento personalizado que está fazendo upgrade do HTML e que inclui uma raiz fantasma declarativa já terá essa raiz paralela anexada. Isso significa que o elemento terá uma propriedade shadowRoot já disponível quando instanciado, sem que o código crie explicitamente uma. É melhor verificar se há alguma raiz paralela em this.shadowRoot no construtor do elemento. Se já houver um valor, o HTML desse componente incluirá uma raiz paralela declarativa. Se o valor for nulo, não há raiz de sombra declarativa presente no HTML, ou o navegador não é compatível com esse DOM.

<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 já havia uma raiz paralela antes de criar uma usando attachShadow(). Apesar disso, o Shadow DOM 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 raiz fantasma Declarativa não gera um erro. Em vez disso, a raiz paralela declarativa é vazia 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 sombra declarativa de um elemento, aberta e fechada. Ela pode ser usada para verificar e usar qualquer raiz de sombra declarativa, enquanto ainda retorna para attachShadow() nos casos em que uma raiz 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 sempre são colocadas junto com o elemento associado. Essa decisão de projeto garante que as raízes paralelas possam ser transmitidas como o resto de um documento HTML. Ele também é conveniente para criação e geração, já que adicionar uma raiz paralela a um elemento não exige a manutenção de um registro das raízes paralelas já existentes.

A desvantagem de associar raízes paralelas ao elemento pai é que vários elementos não podem ser inicializados pela mesma <template> raiz de sombra declarativa. No entanto, é pouco provável que isso seja importante 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, o conteúdo geralmente difere (por exemplo, pequenas variações no texto ou 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. Finalmente, o impacto de raízes paralelas repetidas semelhantes no tamanho de transferência da 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 a modelos integrados, as raízes de sombra declarativas poderão ser tratadas como modelos instanciados para construir a raiz paralela de um determinado elemento. O design declarativo do Shadow DOM permite essa possibilidade no futuro limitando a associação da raiz paralela a um único elemento.

Streaming legal

A associação direta de raízes paralelas declarativas ao elemento pai simplifica o processo de upgrade e anexação delas a esse elemento. As raízes de sombra declarativas são detectadas durante a análise HTML e anexadas imediatamente quando a tag <template> de abertura é encontrada. O HTML analisado dentro do <template> é analisado diretamente na raiz paralela para que possa ser "transmitido": 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 HTML. Isso significa que uma raiz de sombra declarativa só será analisada e anexada para tags <template> com um atributo shadowrootmode que estiverem presentes durante a análise HTML. Em outras palavras, as raízes paralelas declarativas podem ser construídas durante a análise HTML inicial:

<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 fragmento, como innerHTML ou insertAdjacentHTML(). A única maneira de analisar HTML com raízes de sombra declarativas aplicadas é transmitir uma nova opção includeShadowRoots para DOMParser:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root here
</script>

Renderização de servidor com estilo

Folhas de estilo inline e externas são totalmente compatíveis dentro de raízes sombra declarativas usando as tags <style> e <link> padrão:

<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 será carregada e analisada apenas uma vez. O navegador usa uma única CSSStyleSheet de apoio compartilhada por todas as raízes paralelas, eliminando a sobrecarga de memória duplicada.

As folhas de estilo construtí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 construtíveis em HTML nem consultá-las ao preencher adoptedStyleSheets.

Como evitar a propagação de conteúdo sem estilo

Um possível problema em navegadores que ainda não oferecem suporte a esse DOM é 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 FOUC era aplicar uma regra de estilo display:none aos elementos personalizados que ainda não foram carregados, já que eles não tinham a raiz paralela 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 declarativo, os elementos personalizados podem ser renderizados ou criados em HTML para 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, a regra display:none "FOUC" impede que o conteúdo declarativo da raiz paralela apareça. No entanto, remover essa regra faria com que navegadores sem suporte a shadow DOM declarativos exibissem conteúdo incorreto ou sem estilo até que o polyfill do Shadow DOM declarativo carregue e converta o modelo de raiz paralela 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 que não oferecem 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 ocultar o elemento personalizado ainda não definido, a regra "FOUC" revisada oculta os filhos quando eles seguem um elemento <template shadowrootmode>. Uma vez definido o Elemento personalizado, a regra não corresponde mais. A regra é ignorada em navegadores compatíveis com o DOM de sombra declarativa porque o filho <template shadowrootmode> é removido durante a análise do 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 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 ao navegador pode ser detectado ao verificar a existência de uma propriedade shadowRootMode no protótipo de HTMLTemplateElement:

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

Plástico poligonal

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

Leia mais