Shadow DOM v1: componentes da Web independentes

O Shadow DOM permite que desenvolvedores da Web criem DOM e CSS compartimentalizados para componentes da Web

Resumo

O Shadow DOM remove as complicações da criação de apps da Web. Essas complicações são geradas pela natureza global do HTML, do CSS e do JS. Ao longo dos anos, inventamos um número exorbitante de ferramentas para contornar esses problemas. Por exemplo, quando você usa um novo id/classe do HTML, não há como dizer se ele vai entrar em conflito com um nome já usado pela página. Erros sutis aparecem, a especificidade do CSS se torna um grande problema (!important para tudo!), os seletores de estilo crescem descontrolados e o desempenho pode ser afetado. A lista continua.

O Shadow DOM corrige o CSS e o DOM. Ele introduz estilos com escopo na plataforma da Web. Sem ferramentas nem convenções de nomenclatura, você pode empacotar CSS com marcação, ocultar detalhes de implementação e criar componentes independentes no JavaScript simples.

Introdução

O Shadow DOM é um dos três padrões de Web Component: modelos HTML, Shadow DOM e elementos personalizados. As importações de HTML faziam parte da lista, mas agora são consideradas descontinuadas.

Você não precisa criar componentes da Web que usam o shadow DOM. Mas, quando faz isso, aproveita os benefícios (escopo de CSS, encapsulamento de DOM, composição) e cria elementos personalizados reutilizáveis, que são resilientes, altamente configuráveis e extremamente reutilizáveis. Se os elementos personalizados são a forma de criar um novo HTML (com uma API JS), o shadow DOM é a forma de disponibilizar o HTML e o CSS. As duas APIs se combinam para fazer um componente com HTML, CSS e JavaScript independentes.

O Shadow DOM foi projetado como uma ferramenta para a criação de apps baseados em componentes. Assim, ele traz soluções para problemas comuns no desenvolvimento da Web:

  • DOM isolado: o DOM de um componente é independente (por exemplo, document.querySelector() não retorna nós no shadow DOM do componente).
  • CSS com escopo: o CSS definido dentro do shadow DOM assume o escopo dele. As regras de estilo não vazam e os estilos das páginas não interferem.
  • Composição: crie uma API declarativa e baseada em marcação para o componente.
  • Simplifica o CSS: o DOM com escopo significa que você pode usar seletores CSS simples, nomes de ID/classe mais genéricos e não se preocupar com conflitos de nomenclatura.
  • Produtividade: pense nos apps como blocos de DOM em vez de uma página grande (global).

Demonstração de fancy-tabs

Neste artigo, faremos referência a um componente de demonstração (<fancy-tabs>) e a snippets de código dele. Se o seu navegador for compatível com as APIs, uma demonstração ao vivo será exibida logo abaixo. Caso contrário, confira o código-fonte completo no GitHub.

Confira o código-fonte no GitHub

O que é o shadow DOM?

Contexto no DOM

O HTML sustenta a Web porque é fácil trabalhar com ele. Declarando algumas tags, você pode criar uma página em segundos com apresentação e estrutura. No entanto, sozinho, o HTML não é tão útil. É fácil para os humanos entenderem uma linguagem baseada em texto, mas as máquinas precisam de algo mais. Apresentamos o Document Object Model, ou DOM.

Quando o navegador carrega uma página da Web, faz muitas coisas interessantes. Uma delas é transformar o HTML em um documento vivo. Basicamente, para compreender a estrutura da página, o navegador analisa o HTML (strings estáticos de texto) e o converte em um modelo de dados (objetos/nós). O navegador preserva a hierarquia do HTML criando uma árvore desses nós: o DOM. O legal do DOM é que ele é uma representação em tempo real da página. Ao contrário do HTML estático que criamos, os nós gerados pelo navegador contêm propriedades, métodos e, o melhor de tudo... podem ser manipulados por programas! É por isso que conseguimos criar elementos do DOM diretamente usando JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

gera a seguinte marcação HTML:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Tudo isso é ótimo. Então, o que é esse tal de shadow DOM?

DOM… em segundo plano

O Shadow DOM é apenas o DOM normal, com duas diferenças: 1) a forma como é criado/usado e 2) a forma como se comporta em relação ao resto da página. Normalmente, você cria nós do DOM e os anexa como filhos de outro elemento. Com o shadow DOM, você cria uma árvore do DOM com escopo que é anexada ao elemento, mas separada de seus filhos reais. Essa subárvore com escopo é chamada de árvore paralela. O elemento a que ele está anexado é o host paralelo. Tudo o que você adiciona em paralelo se torna local ao elemento host, incluindo <style>. É assim que o shadow DOM define um escopo para o estilo do CSS.

Criação de shadow DOM

Uma raiz paralela é um fragmento de documento anexado a um elemento "host". O elemento obtém o shadow DOM mediante a anexação de uma raiz paralela. Para criar um shadow DOM para um elemento, chame element.attachShadow():

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

Estou usando .innerHTML para preencher a raiz paralela, mas outras APIs do DOM também podem ser usadas. Estamos na Web. Temos opções.

A especificação define uma lista de elementos que não podem hospedar uma árvore paralela. Há vários motivos para que um elemento esteja na lista:

  • O navegador já hospeda seu próprio shadow DOM interno para o elemento (<textarea>, <input>).
  • Não faz sentido que o elemento hospede um (<img>) do shadow DOM.

Por exemplo, isso não funciona:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Criar um shadow DOM para um elemento personalizado

O Shadow DOM é particularmente útil na criação de elementos personalizados. Use o shadow DOM para compartimentalizar o HTML, o CSS e o JS de um elemento, criando um "componente da Web".

Exemplo: um elemento personalizado anexa o shadow DOM a si mesmo, encapsulando o DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Algumas coisas interessantes estão acontecendo. A primeira é que o elemento personalizado cria seu próprio shadow DOM quando uma instância de <fancy-tabs> é criada. Isso é feito no constructor(). Em segundo lugar, como estamos criando uma raiz paralela, as regras do CSS dentro de <style> vão assumir o escopo de <fancy-tabs>.

Composição e slots

A composição é um dos recursos menos compreendidos do shadow DOM, mas é provavelmente o mais importante.

No nosso mundo de desenvolvimento da Web, a composição nos permite criar aplicativos de forma declarativa usando HTML. Blocos básicos diferentes (<div>s, <header>s, <form>s, <input>s) são reunidos para formar apps. Algumas dessas tags até trabalham juntamente com as outras. A composição permite que elementos nativos como <select>, <details>, <form> e <video> sejam tão flexíveis. Cada uma dessas tags aceita determinados HTML como filhos e faz algo especial com eles. Por exemplo, <select> sabe como renderizar <option> e <optgroup> em widgets suspensos e de seleção múltipla. O elemento <details> renderiza <summary> como uma seta expansível. Até mesmo <video> sabe como lidar com determinados filhos: os elementos <source> não são renderizados, mas afetam o comportamento do vídeo. Isso é mágico!

Terminologia: light DOM x shadow DOM

A composição do Shadow DOM introduz vários conceitos básicos novos no desenvolvimento da Web. Antes de entrarmos em detalhes, vamos padronizar a terminologia para falarmos o mesmo idioma.

DOM leve

A marcação escrita por um usuário do seu componente. Esse DOM reside fora do shadow DOM do componente. Ele consiste nos filhos reais do elemento.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

O DOM escrito pelo autor do componente. O Shadow DOM é local em relação ao componente e define a estrutura interna e o CSS com escopo, bem como encapsula os detalhes da implementação. Além disso, ele define como renderizar marcação criada pelo consumidor do seu componente.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Árvore plana do DOM

O resultado da distribuição do light DOM do usuário pelo navegador no shadow DOM, renderizando o produto final. A árvore plana é o que você finalmente vai encontrar no DevTools e o que será renderizado na página.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

O elemento <slot>

O Shadow DOM compõe árvores do DOM diferentes, juntando-as usando o elemento <slot>. Os slots são marcadores dentro do componente que os usuários podem preencher com sua própria marcação. A definição de um ou mais slots permite que marcações externas sejam renderizadas no shadow DOM do componente. Essencialmente, você está dizendo "renderize a marcação do usuário aqui".

Os elementos podem "cruzar" a fronteira do shadow DOM quando convidados por um <slot>. Esses elementos são chamados de nós distribuídos. Conceitualmente, os nós distribuídos podem parecer um pouco estranhos. Os slots não movem fisicamente o DOM. Eles o renderizam em outro local, dentro do shadow DOM.

Um componente pode definir zero ou mais slots no shadow DOM. Os slots podem estar vazios ou fornecer conteúdo de fallback. Se o usuário não fornecer conteúdo do light DOM, o slot vai renderizar o conteúdo de fallback.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

Também é possível criar slots nomeados. Os slots nomeados são compartimentos específicos no DOM de sombra que os usuários referenciam por nome.

Exemplo: os slots no shadow DOM de <fancy-tabs>:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Os usuários do componente declaram <fancy-tabs> desta forma:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

E, caso você esteja imaginando, a árvore plana tem a seguinte aparência:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Observe que o nosso componente pode tratar configurações diferentes, mas a árvore plana do DOM permanece a mesma. Também podemos mudar de <button> para <h2>. Esse componente foi criado para tratar tipos diferentes de filhos... da mesma forma que o <select>.

Estilo

Há várias opções para aplicar estilo a componentes da Web. Um componente que usa shadow DOM pode ser estilizado pela página principal, definir seus próprios estilos ou fornecer ganchos (na forma de propriedades personalizadas do CSS) para que os usuários modifiquem os padrões.

Estilos definidos pelo componente

O recurso mais útil do shadow DOM é o CSS com escopo:

  • Os seletores CSS da página externa não se aplicam dentro do componente.
  • Os estilos definidos dentro do componente não vazam para fora. Seu escopo é limitado ao elemento host.

Os seletores CSS usados dentro do shadow DOM se aplicam localmente ao componente. Na prática, isso significa que podemos usar nomes de ID/classe comuns novamente, sem nos preocuparmos com conflitos com outros locais da página. Os seletores CSS mais simples são uma prática recomendada no Shadow DOM. Além disso, ajudam a melhorar o desempenho.

Exemplo: os estilos definidos em uma raiz paralela são locais

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

As folhas de estilo também assumem o escopo da árvore paralela:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Você já se perguntou como o elemento <select> renderiza um widget de seleção múltipla (em vez de um suspenso) quando você adiciona o atributo multiple?

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> pode aplicar estilo de forma diferente com base nos atributos declarados para ele. Os componentes da Web também podem aplicar estilo a si mesmos usando o seletor :host.

Exemplo: um componente aplicando estilo a si mesmo

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Um problema do :host é que as regras na página mãe têm especificidade maior do que as regras :host definidas no elemento. Ou seja, os estilos externos prevalecem. Isso permite que os usuários modifiquem externamente o estilo de alto nível do componente. Além disso, :host funciona apenas no contexto de uma raiz paralela. Portanto, não pode ser usada fora do shadow DOM.

A forma funcional de :host(<selector>) permite que você atue no host se ele corresponder a um <selector>. Essa é uma ótima forma de seu componente encapsular comportamentos que reagem à interação do usuário ou declarar ou aplicar estilo a nós internos baseados no host.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Estilo com base no contexto

:host-context(<selector>) corresponde ao componente se ele ou qualquer um dos ancestrais corresponder a <selector>. Um uso comum para isso é aplicação de temas de acordo com as áreas próximas ao componente. Por exemplo, muitas pessoas implementam temas aplicando uma classe a <html> ou <body>:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) estilizaria <fancy-tabs> quando fosse um descendente de .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() pode ser útil para aplicação de temas, mas uma abordagem ainda melhor é criar ganchos de estilo usando propriedades personalizadas do CSS.

Aplicação de estilo em nós distribuídos

::slotted(<compound-selector>) corresponde a nós que são distribuídos em um <slot>.

Vamos supor que criamos um componente de crachá:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

O shadow DOM do componente pode aplicar estilo ao <h2> e .title do usuário:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Como vimos antes, os <slot>s não movimentam o light DOM do usuário. Quando os nós são distribuídos em um <slot>, o <slot> renderiza o DOM, mas os nós permanecem fisicamente fixos. Os estilos aplicados antes da distribuição continuam a ser aplicados após a distribuição. No entanto, quando o light DOM é distribuído, ele pode assumir estilos adicionais (definidos pelo shadow DOM).

Outro exemplo mais detalhado de <fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

Nesse exemplo, há dois espaços: um nomeado para os títulos da guia e outro para o conteúdo do painel de guias. Quando um usuário seleciona uma guia, aplicamos negrito à seleção e revelamos o painel dela. Isso é feito selecionando nós distribuídos com o atributo selected. O JS do elemento personalizado (não mostrado aqui) adiciona esse atributo no momento certo.

Aplicar estilo a um componente externo

Há algumas maneiras de aplicar externamente estilo a um componente. A maneira mais fácil é usar o nome da tag como seletor:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Estilos externos sempre prevalecem sobre estilos definidos no shadow DOM. Por exemplo, se o usuário escrever o seletor fancy-tabs { width: 500px; }, ele vai prevalecer sobre a regra do componente: :host { width: 650px;}.

Aplicando um estilo ao próprio componente produz resultados limitados. Mas o que acontece se você quiser aplicar estilo internamente a um componente? Para isso, precisamos de propriedades personalizadas do CSS.

Criar ganchos de estilo usando propriedades personalizadas do CSS

Os usuários poderão ajustar estilos internos se o autor do componente fornecer ganchos para aplicação de estilo usando propriedades personalizadas do CSS. Conceitualmente, a ideia é semelhante a <slot>. Você cria "marcadores de estilo" para que os usuários possam substituir.

Exemplo: <fancy-tabs> permite que os usuários modifiquem a cor do segundo plano:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Dentro do shadow DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

Nesse caso, o componente usa black como o valor de segundo plano, porque o usuário o forneceu. Caso contrário, assume o valor padrão de #9E9E9E.

Temas avançados

Criar raízes paralelas fechadas (não recomendado)

Há uma outra variação do shadow DOM chamada modo "fechado". Quando você cria uma árvore paralela fechada, o JavaScript externo não consegue acessar o DOM interno do componente. O funcionamento é semelhante ao de elementos nativos, como <video>. O JavaScript não pode acessar o shadow DOM de <video> porque o navegador o implementa usando uma raiz paralela de modo fechado.

Exemplo: criar uma árvore paralela fechada:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

Outras APIs também são afetadas pelo modo fechado:

  • Element.assignedSlot / TextNode.assignedSlot retorna null
  • Event.composedPath() para eventos associados a elementos dentro do shadow DOM, retorna []

Confira um resumo dos motivos pelos quais você nunca deve criar componentes da Web com {mode: 'closed'}:

  1. Sensação artificial de segurança. Não há nada que impeça um atacante de sequestrar Element.prototype.attachShadow.

  2. O modo fechado impede que o código do elemento personalizado acesse o próprio shadow DOM. Isso é um desastre. Em vez disso, você terá de guardar uma referência para uso posterior se quiser usar algo como querySelector(). Isso invalida totalmente o propósito original do modo fechado.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. O modo fechado reduz a flexibilidade do seu componente para os usuários finais. Na criação de componentes da Web, chegará o momento em que você esquecerá de adicionar um recurso. Uma opção de configuração. Um caso de uso desejado pelo usuário. Um exemplo comum é esquecer de incluir ganchos de estilo adequados para nós internos. Com o modo fechado, não há como os usuários modificarem padrões e alterar estilos. A capacidade de acessar internamente os componentes é muito útil. No final, se o seu componente não fizer o que os usuários desejam, eles o mudarão, encontrarão outro ou criarão o próprio componente :(

Como trabalhar com slots no JS

A API do shadow DOM oferece utilitários para trabalhar com slots e nós distribuídos. Eles são úteis para criar um elemento personalizado.

evento slotchange

O evento slotchange é acionado quando os nós distribuídos de um slot são alterados. Por exemplo, se o usuário adicionar/remover filhos do light DOM.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

Para monitorar outros tipos de mudanças no light DOM, você pode configurar um MutationObserver no construtor do elemento.

Quais elementos estão sendo renderizados em um slot?

Às vezes, é útil saber quais elementos estão associados a um slot. Chame slot.assignedNodes() para saber quais elementos o slot está renderizando. A opção {flatten: true} também vai retornar o conteúdo de fallback de um slot (se nenhum nó estiver sendo distribuído).

Como exemplo, vamos supor que o shadow DOM é semelhante a este:

<slot><b>fallback content</b></slot>
UsoLigarResultado
<my-component>texto do componente</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

A qual slot um elemento está atribuído?

Também é possível responder à pergunta inversa. element.assignedSlot informa a quais slots de componente o elemento está atribuído.

Modelo de eventos do Shadow DOM

Quando um evento surge do shadow DOM, o destino é ajustado para manter o encapsulamento oferecido pelo shadow DOM. Ou seja, os eventos são redirecionados para parecer que foram originados do componente e não de elementos internos no shadow DOM. Alguns eventos nem mesmo são propagados para fora do shadow DOM.

Os eventos que cruzam a fronteira da sombra são:

  • Eventos de foco: blur, focus, focusin, focusout
  • Eventos de mouse: click, dblclick, mousedown, mouseenter, mousemove etc.
  • Eventos de roda: wheel
  • Eventos de entrada: beforeinput, input
  • Eventos de teclado: keydown, keyup
  • Eventos de composição: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop etc.

Dicas

Se a árvore paralela estiver aberta, a chamada de event.composedPath() vai retornar uma matriz de nós percorridos pelo evento.

Usar eventos personalizados

Os eventos personalizados de DOM acionados em nós internos de uma árvore paralela não cruzam os limites do Shadow DOM, a menos que o evento seja criado usando a flag composed: true:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Se composed: false (padrão), os consumidores não poderão ouvir o evento fora da raiz paralela.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Processamento de foco

Se você se lembrar do modelo de evento do shadow DOM, os eventos que são acionados dentro do shadow DOM são ajustados para parecer que vêm do elemento de hospedagem. Por exemplo, digamos que você clique em um <input> dentro de uma raiz de sombra:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

O evento focus vai parecer que veio de <x-focus>, não de <input>. Da mesma forma, document.activeElement será <x-focus>. Se a raiz paralela foi criada com mode:'open' (consulte modo fechado), você também poderá acessar o nó interno que ganhou foco:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Se houver vários níveis de shadow DOM em jogo (digamos, um elemento personalizado dentro de outro elemento personalizado), é preciso detalhar as raízes de shadow recursivamente para encontrar o activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Outra opção para o foco é a opção delegatesFocus: true, que expande o comportamento de foco do elemento dentro de uma árvore de shadow:

  • Se você clicar em um nó dentro do shadow DOM e o nó não for uma área focalizável, a primeira área focalizável será focada.
  • Quando um nó dentro do shadow DOM ganha foco, :focus se aplica ao host, além do elemento focado.

Exemplo: como delegatesFocus: true muda o comportamento de foco

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Result

delegatesFocus: comportamento verdadeiro.

Acima está o resultado quando <x-focus> está focado (clique do usuário, com guias até focus() etc.). "Clickable Shadow DOM text" é clicado ou o <input> interno é focado (incluindo autofocus).

Se você definisse delegatesFocus: false, eis o que você veria:

delegatesFocus: false e a entrada interna está em foco.
delegatesFocus: false e o <input> interno está focado.
delegatesFocus: false e x-focus
    ganham foco (por exemplo, ele tem tabindex=&#39;0&#39;).
delegatesFocus: false e <x-focus> ganham foco (por exemplo, têm tabindex="0").
delegatesFocus: false e &quot;Clickable Shadow DOM text&quot; é
    clicado (ou outra área vazia dentro do shadow DOM do elemento é clicada).
delegatesFocus: false e "Clickable Shadow DOM text" é clicado (ou outra área vazia dentro do shadow DOM do elemento é clicada).

Dicas e sugestões

Nos últimos anos, aprendi algumas coisas sobre a criação de componentes da Web. Acredito que algumas dessas dicas serão úteis para criar componentes e depurar o shadow DOM.

Usar a contenção do CSS

Normalmente, o layout/estilo/pintura de um componente da Web é razoavelmente independente. Use a contenção do CSS em :host para um ganho de desempenho:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Como redefinir estilos herdáveis

Os estilos herdáveis (background, color, font, line-height etc.) continuam a herdar no shadow DOM. Ou seja, eles cruzam o limite do shadow DOM por padrão. Se você quiser começar do zero, use all: initial; para redefinir os estilos herdáveis ao valor inicial quando eles cruzarem o limite da sombra.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Como encontrar todos os elementos personalizados usados por uma página

Às vezes, é útil encontrar os elementos personalizados usados na página. Para fazer isso, você precisa percorrer de forma recursiva o shadow DOM de todos os elementos usados na página.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Criar elementos usando um <template>

Em vez de preencher uma raiz paralela usando .innerHTML, podemos usar um <template> declarativo. Os modelos são um marcador ideal para declarar a estrutura de um componente da Web.

Confira o exemplo em "Elementos personalizados: criar componentes da Web reutilizáveis".

Histórico e suporte a navegadores

Se você acompanhou os componentes da Web durante os últimos dois anos, já sabe que os navegadores Chrome 35+/Opera estão fornecendo uma versão mais antiga do shadow DOM há algum tempo. O Blink continuará a oferecer suporte a ambas as versões em paralelo por algum tempo. A especificação v0 oferecia um método diferente para criar uma raiz paralela (element.createShadowRoot em vez do element.attachShadow da v1). A chamada do método mais antigo continua a criar uma raiz paralela com semântica da v0. Portanto, o código v0 atual continuará a funcionar.

Se você ainda estiver interessado na especificação v0 antiga, confira os artigos de html5rocks: 1, 2 e 3. Há também uma ótima comparação das diferenças entre o shadow DOM v0 e o v1.

Suporte ao navegador

O Shadow DOM v1 é enviado no Chrome 53 (status), Opera 40, Safari 10 e Firefox 63. O Edge começou o desenvolvimento.

Para detectar a disponibilidade do shadow DOM, verifique a existência de attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Até que o suporte ao navegador esteja amplamente disponível, os polyfills shadydom e shadycss oferecem o recurso v1. O Shady DOM imita o escopo DOM do shadow DOM e propriedades personalizadas de CSS de polyfills shadycss e o escopo de estilo que a API nativa oferece.

Instale os polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Use os polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

Consulte https://github.com/webcomponents/shadycss#usage para receber instruções sobre como preencher/estender seus estilos.

Conclusão

Pela primeira vez, temos um primitivo de API que oferece escopo adequado de CSS e de DOM e oferece composição real. Combinado com outras APIs de componentes da Web como elementos personalizados, o shadow DOM oferece uma forma de criar componentes verdadeiramente encapsulados, sem truques nem recursos antiquados como <iframe>s.

Não me entenda mal. O Shadow DOM é certamente muito complexo. Mas vale muito a pena aprender a usá-lo. Invista algum tempo nele. Aprenda e faça perguntas!

Leitura adicional

Perguntas frequentes

Já posso usar o Shadow DOM v1?

Com um polyfill, sim. Consulte Suporte a navegadores.

Quais os recursos de segurança oferecidos pelo shadow DOM?

O Shadow DOM não é um recurso de segurança. É uma ferramenta leve para aplicar escopo ao CSS e ocultar árvores do DOM no componente. Se você quiser um limite de segurança verdadeiro, use um <iframe>.

O componente da Web precisa usar um shadow DOM?

Não. Você não precisa criar componentes da Web que usam o shadow DOM. No entanto, a criação de elementos personalizados que usam o Shadow DOM significa que você pode aproveitar recursos como atribuição de escopo para CSS, encapsulamento do DOM e composição.

Qual é a diferença entre raízes paralelas abertas e fechadas?

Consulte Raízes paralelas fechadas.