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 vulnerabilidades da criação de apps da Web. Essa dificuldade vem da natureza global do HTML, do CSS e do JS. Ao longo dos anos, inventamos um número exorbitante de tools para contornar os problemas. Por exemplo, quando você usa um novo ID/classe HTML, não há como saber se ele entrará em conflito com um nome existente usado pela página. Bugs sutis aparecem, a especificidade do CSS se torna um grande problema (!important todas as coisas!), os seletores de estilo ficam fora de controle e o desempenho pode ser afetado. A lista continua.

O Shadow DOM corrige o CSS e o DOM. Ela introduz estilos com escopo na plataforma da Web. Sem ferramentas ou convenções de nomenclatura, é possível agrupar CSS com marcação, ocultar detalhes de implementação e criar componentes independentes em JavaScript simples.

Introdução

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

Você não precisa criar componentes da Web que usam o shadow DOM. Mas, ao fazer isso, você 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 maneira de fornecer o HTML e o CSS dele. 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 aplicativos baseados em componentes. Portanto, 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 tem seu escopo definido. 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 é possível usar seletores de CSS simples, nomes de ID/classe mais genéricos sem se preocupar com conflitos de nomenclatura.
  • Produtividade: pense nos apps como blocos de DOM em vez de uma página grande (global).

Demonstração do fancy-tabs

Neste artigo, faremos referência a um componente de demonstração (<fancy-tabs>) e a snippets de código dele. Se o 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.

Acessar o código-fonte no GitHub

O que é o shadow DOM?

Segundo plano do DOM

O HTML impulsiona 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. Os humanos conseguem entender uma linguagem baseada em texto com facilidade, mas as máquinas precisam de algo mais. Apresentamos o Document Object Model, ou DOM.

Quando o navegador carrega uma página da Web, ele faz várias coisas interessantes. Uma delas é transformar o HTML do autor em um documento ao vivo. Basicamente, para entender a estrutura da página, o navegador analisa o HTML (strings estáticas de texto) 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 real da sua 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 podemos 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);

produz a seguinte marcação HTML:

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

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

DOM... nas sombras

O Shadow DOM é apenas o DOM normal, com duas diferenças: 1) como ele é criado/usado e 2) como ele se comporta em relação ao restante 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 dos 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 de hospedagem, incluindo <style>. É assim que o shadow DOM alcança o escopo de estilo do CSS.

Como criar o shadow DOM

Uma raiz paralela é um documento fragmentado anexado a um elemento "host". O elemento pode obter seu shadow DOM mediante a anexação de uma raiz paralela. Para criar o 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. Esta é a 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 um elemento estar na lista:

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

Por exemplo, isto não funciona:

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

Como criar o shadow DOM para um elemento personalizado

O Shadow DOM é particularmente útil na criação de elementos personalizados. Use o shadow DOM para compartimentar o HTML, o CSS e o JS de um elemento e, assim, produzir 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 aqui. A primeira é que o elemento personalizado cria o 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 CSS dentro de <style> terão o escopo definido como <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 é a maneira como construímos apps de maneira declarativa usando HTML. Diferentes elementos básicos (<div>s, <header>s, <form>s, <input>s) são reunidos para formar apps. Algumas dessas tags até funcionam umas com as outras. É por isso que elementos nativos, como <select>, <details>, <form> e <video>, são tão flexíveis. Cada uma dessas tags aceita determinados HTMLs como filhos e fazem algo especial com eles. Por exemplo, <select> sabe como renderizar <option> e <optgroup> em widgets de menu suspenso e 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. Que mágica!

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 entrar em detalhes, vamos padronizar a terminologia para falarmos a mesma linguagem.

Light DOM

É a marcação que um usuário do seu componente escreve. Esse DOM reside fora do shadow DOM do componente. São os 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 que o autor de um componente escreve. O Shadow DOM é local em relação ao componente e define a estrutura interna e o CSS com escopo, além de encapsular os detalhes da implementação. Ele também pode definir como renderizar a marcação criada pelo consumidor do componente.

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

Árvore do DOM nivelada

O resultado da distribuição do light DOM do usuário pelo navegador no shadow DOM, renderizando o produto final. A árvore nivelada é o que você verá 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 diferentes árvores do DOM usando o elemento <slot>. Os slots são marcadores dentro do componente que os usuários podem preencher com a própria marcação. Ao definir um ou mais slots, você convida marcações externas para renderizar no shadow DOM do componente. Essencialmente, você está dizendo "Renderize a marcação do usuário aqui".

Os elementos podem "cruzar" o limite do shadow DOM quando um <slot> os convida. Esses elementos são chamados de nós distribuídos. Conceitualmente, os nós distribuídos podem parecer um pouco bizarros. 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 substituto. Se o usuário não fornecer conteúdo do light DOM, o slot renderizará o conteúdo substituto.

<!-- 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 espaços específicos no shadow DOM que os usuários podem referenciar pelo 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 o <fancy-tabs> da seguinte maneira:

<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 se perguntando, 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 nosso componente pode processar configurações diferentes, mas a árvore nivelada do DOM permanece a mesma. Também podemos mudar de <button> para <h2>. Esse componente foi criado para lidar com diferentes tipos de filhos, assim como o <select> faz.

Estilo

Há muitas opções para estilizar componentes da Web. Um componente que usa o shadow DOM pode ser estilizado pela página principal, definir os 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 de CSS da página externa não se aplicam dentro do componente.
  • Os estilos definidos dentro não vazam. O escopo deles é definido para o elemento host.

Os seletores CSS usados no shadow DOM aplicam-se localmente ao seu componente. Na prática, isso significa que podemos usar nomes de ID/classe comuns novamente, sem nos preocupar com conflitos com outros locais da página. Os seletores CSS mais simples são uma prática recomendada no Shadow DOM. Eles também melhoram 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 têm como escopo a á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 menu 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>

A <select> pode estilizar a si mesma de forma diferente com base nos atributos declarados nela. 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 vencem. Isso permite que os usuários modifiquem externamente o estilo de nível superior. Além disso, o :host só funciona no contexto de uma raiz paralela. Portanto, não é possível usá-la fora do shadow DOM.

A forma funcional de :host(<selector>) permite direcionar o host se ele corresponder a um <selector>. Essa é uma ótima maneira de seu componente encapsular comportamentos que reagem à interação do usuário ou declarar ou estilizar nós internos com base 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>) corresponderá ao componente se ele ou qualquer um dos ancestrais corresponder a <selector>. Um uso comum para isso é aplicação em temas com base nos arredores de um componente. Por exemplo, muitas pessoas aplicam temas aplicando uma classe a <html> ou <body>:

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

:host-context(.darktheme) define o estilo de <fancy-tabs> quando é descendente de .darktheme:

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

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

Como definir o estilo de nós distribuídos

::slotted(<compound-selector>) corresponde a nós 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 estilizar o <h2> e o .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 já mencionamos, as <slot>s não movem 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 ficam 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 slots: um nomeado para os títulos da guia e um para o conteúdo do painel da guia. Quando o usuário seleciona uma guia, aplicamos negrito à seleção e revelamos o painel. Isso é feito selecionando nós distribuídos que têm o atributo selected. O JS do elemento personalizado (não mostrado aqui) adiciona esse atributo no momento correto.

Como definir o estilo de um componente externamente

Existem algumas maneiras de definir o estilo externo de 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 sobrepor a regra do componente: :host { width: 650px;}.

Aplicar um estilo ao próprio componente só leva você até certo ponto. Mas o que acontece se você quiser aplicar estilo internamente a um componente? Para isso, precisamos das propriedades personalizadas do CSS.

Criar ganchos de estilo usando propriedades personalizadas de CSS

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

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

<!-- 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 vai usar black como o valor em segundo plano, já que o usuário o forneceu. Caso contrário, o padrão será #9E9E9E.

Temas avançados

Criar raízes paralelas fechadas (o que deve ser evitado)

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

Exemplo: como 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 []

Veja a seguir 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 invasor de invadir 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á que guardar uma referência para mais tarde se quiser usar itens como querySelector(). Isso invalida completamente 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 torna seu componente menos flexível para os usuários finais. Ao criar componentes da Web, chegará o momento em que você se esquecerá de adicionar um recurso. Uma opção de configuração. Um caso de uso que o usuário quer. Um exemplo comum é esquecer de incluir ganchos de estilo adequados para nós internos. Com o modo fechado, não é possível substituir os padrões e ajustar os estilos. Poder acessar os componentes internos do componente é muito útil. Em última análise, os usuários vão bifurcar seu componente, encontrar outro ou criar o próprio componente, caso ele não faça o que querem :(

Como trabalhar com slots no JS

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

evento slotchange

O evento slotchange é disparado quando os nós distribuídos de um slot mudam. 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, configure 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 retornará o conteúdo substituto de um slot (se nenhum nó estiver sendo distribuído).

Como exemplo, digamos que o shadow DOM tenha a seguinte aparência:

<slot><b>fallback content</b></slot>
UsoCallResultado
<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 que slot um elemento é atribuído?

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

O modelo de eventos do Shadow DOM

Quando um evento surge do shadow DOM, seu destino é ajustado para manter o encapsulamento oferecido pelo shadow DOM. Ou seja, os eventos são segmentados novamente para parecer que vieram 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 o limite 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 do teclado: keydown, keyup
  • Eventos de composição: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop etc.

Dicas

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

Usar eventos personalizados

Os eventos personalizados de DOM que são disparados em nós internos em uma árvore paralela não saem do limite do shadow, a menos que o evento seja criado usando a sinalização 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 for composed: false (padrão), os consumidores não poderão detectar 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

Como mencionado no modelo de eventos do shadow DOM, os eventos acionados dentro do shadow DOM são ajustados para parecerem ter vindo do elemento de hospedagem. Por exemplo, digamos que você clique em <input> dentro de uma raiz paralela:

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

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

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

Se houver vários níveis de shadow DOM em jogo (por exemplo, um elemento personalizado em outro elemento personalizado), será necessário detalhar as raízes 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 de foco é a delegatesFocus: true, que expande o comportamento de foco do elemento dentro de uma árvore paralela:

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

Exemplo: como delegatesFocus: true muda o comportamento do 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

delegadosFocus: comportamento verdadeiro.

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

Se você definisse delegatesFocus: false, o seguinte seria exibido:

DelegsFocus: falso e a entrada interna está focada.
delegatesFocus: false e o <input> interno está em foco.
delegadosFocus: false e x-focus recebe foco (por exemplo, tem tabindex=&#39;0&#39;).
delegatesFocus: false e <x-focus> recebem foco (por exemplo, têm tabindex="0").
delegadosFocus: o botão &quot;false&quot; e &quot;Clickable Shadow DOM text&quot; são clicados (ou outra área vazia dentro do shadow DOM do elemento é clicada).
delegatesFocus: false e "Clickable Shadow DOM text" são clicados (ou outra área vazia dentro do shadow DOM do elemento é clicada).

Dicas e sugestões

Ao longo dos 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 contenção de CSS

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

<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 cruzarem o limite do 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>

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 maneira 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('*'));

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

Veja o exemplo em Elementos personalizados: como criar componentes da Web reutilizáveis.

Histórico e suporte do navegador

Se você acompanhou componentes da Web nos últimos anos, já sabe que o Chrome 35+/Opera está fornecendo uma versão mais antiga do shadow DOM há algum tempo. O Blink continuará a oferecer suporte às duas versões em paralelo por algum tempo. A especificação v0 forneceu um método diferente para criar uma raiz paralela (element.createShadowRoot em vez do element.attachShadow da v1). Chamar o método mais antigo continua criando uma raiz paralela com semântica da v0. Dessa forma, o código da v0 existente não será corrompido.

Se você tiver interesse na especificação antiga v0, confira os artigos do html5rocks: 1, 2 e 3 (links em inglês). Há também uma ótima comparação das diferenças entre o shadow DOM v0 e v1.

Suporte ao navegador

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

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

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Plástico poligonal

Até que a compatibilidade com navegadores esteja amplamente disponível, os polyfills shadydom e shadycss oferecem o recurso v1. O Shady DOM imita o escopo DOM do Shadow DOM e as propriedades personalizadas de polyfills do shadycss CSS 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 (em inglês) para instruções sobre como ajustar/definir o escopo dos seus estilos.

Conclusão

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

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

Leia mais

Perguntas frequentes

Posso usar o Shadow DOM v1 hoje?

Com um polyfill, sim. Consulte Compatibilidade do navegador.

Quais recursos de segurança o shadow DOM oferece?

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

Um componente da Web precisa usar o shadow DOM?

Não, Você não precisa criar componentes da Web que usam o shadow DOM. No entanto, ao criar elementos personalizados que usam o Shadow DOM, é possível aproveitar recursos como escopo de CSS, encapsulamento de DOM e composição.

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

Consulte Raízes paralelas fechadas.