Deklaracyjny DOM cienia

Deklaratywna warstwa shadow DOM to standardowa funkcja platformy internetowej, która jest obsługiwana w Chrome od wersji 90. Pamiętaj, że specyfikacja tej funkcji została zmieniona w 2023 r. (w tym zmieniono nazwę z shadowroot na shadowrootmode), a najnowsze ujednolicone wersje wszystkich części tej funkcji zostały wprowadzone w Chrome 124.

Obsługa przeglądarek

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

Źródło

Shadow DOM to jeden z 3 standardów Web Components, uzupełniony przez szablony HTMLelementy niestandardowe. Shadow DOM umożliwia ograniczenie zakresu stylów CSS do konkretnego poddrzewa DOM i odizolowanie tego poddrzewa od reszty dokumentu. Element <slot> umożliwia nam kontrolowanie, gdzie w drzewie cienia elementu niestandardowego mają być wstawiane elementy podrzędne. Te funkcje umożliwiają tworzenie niezależnych, wielokrotnego użytku komponentów, które można łatwo zintegrować z dotychczasowymi aplikacjami tak jak wbudowany element HTML.

Do tej pory jedynym sposobem na korzystanie z Shadow DOM było tworzenie korzenia schattenowego za pomocą JavaScriptu:

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

Taki imperatywny interfejs API dobrze sprawdza się w renderowaniu po stronie klienta: te same moduły JavaScriptu, które definiują nasze elementy niestandardowe, tworzą też ich korzenie cienia i ustalają ich zawartość. Jednak wiele aplikacji internetowych musi renderować treści po stronie serwera lub w postaci statycznego kodu HTML w momencie kompilacji. Może to być ważne, aby zapewnić użytkownikom wygodę korzystania z witryny, nawet jeśli nie mogą uruchomić kodu JavaScript.

Uzasadnienie korzystania z renderowania po stronie serwera (SSR) różni się w zależności od projektu. Aby spełniać wytyczne dotyczące dostępności, niektóre witryny muszą udostępniać w pełni funkcjonalny kod HTML renderowany po stronie serwera, a inne decydują się na podstawową wersję bez JavaScriptu, aby zapewnić dobrą wydajność przy wolnym połączeniu lub na wolnych urządzeniach.

W przeszłości trudno było używać Shadow DOM w połączeniu z renderowaniem po stronie serwera, ponieważ nie było wbudowanego sposobu na wyrażanie korzeni cienia w kodzie HTML generowanym przez serwer. Włączanie źródeł cieni do elementów DOM, które zostały już wyrenderowane bez nich, również ma wpływ na wydajność. Może to spowodować przesunięcie układu po załadowaniu strony lub czasowe wyświetlenie niestylizowanych treści podczas wczytywania arkuszy stylów katalogu głównego cienia.

Deklaratywna warstwa Shadow DOM (DSD) eliminuje to ograniczenie, przenosząc Shadow DOM na serwer.

Jak utworzyć deklaratywny korzeń cienia

Deklaratywna korzeń cienia to element <template> z atrybutem shadowrootmode:

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

Element szablonu z atrybutem shadowrootmode jest wykrywany przez parsujący HTML i natychmiast stosowany jako korzeń cienia elementu nadrzędnego. Załadowanie czystego znacznika HTML z powyższego przykładu powoduje powstanie tego drzewa DOM:

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

Ten przykładowy kod stosuje konwencje panelu Elementy w Chrome DevTools dotyczące wyświetlania treści Shadow DOM. Na przykład znak  oznacza treści Light DOM umieszczone w przedziałach.

Dzięki temu możemy korzystać z zalet enkapsulacji i projekcji slotu w statycznej wersji kodu HTML. Do wygenerowania całego drzewa, w tym korzenia cienia, nie jest potrzebny kod JavaScript.

Nawodnienie komponentu

Deklaratywny Shadow DOM może być używany samodzielnie do opakowywania stylów lub dostosowywania umieszczania elementów podrzędnych, ale najlepiej sprawdza się w połączeniu z elementami niestandardowymi. Komponenty utworzone za pomocą elementów niestandardowych są automatycznie aktualizowane ze statycznego kodu HTML. Dzięki wprowadzeniu deklaratywnego modelu Shadow DOM element niestandardowy może mieć korzeń cienia, zanim zostanie zaktualizowany.

Element niestandardowy, który jest ulepszany z HTML-a i zawiera deklaratywny korzeń cienia, będzie już miał ten korzeń cienia dołączony. Oznacza to, że element będzie miał już dostępną właściwość shadowRoot w momencie utworzenia jego instancji, bez konieczności tworzenia jej przez kod. Najlepiej sprawdzić this.shadowRoot, czy w konstruktoramie elementu nie ma już ukorzeniania cieniowanego. Jeśli wartość już istnieje, kod HTML tego komponentu zawiera deklaratywny korzeń cienia. Jeśli wartość jest pusta, w kodzie HTML nie ma deklaratywnego korzenia cienia lub przeglądarka nie obsługuje deklaratywnego DOM cienia.

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

Elementy niestandardowe istnieją już od jakiegoś czasu, ale do tej pory nie było powodu, aby przed utworzeniem elementu korzystania z funkcji attachShadow() sprawdzać, czy nie ma już istniejącego elementu korzystania z funkcji skryptu. Deklaratywna DOM cienia zawiera niewielką zmianę, która pozwala istniejącym komponentom działać pomimo tej zmiany: wywołanie metody attachShadow() w elemencie z dotychczasowym deklaratywnym korzenia cienia nie spowoduje błędu. Zamiast tego deklaratywny korzeń cienia jest opróżniany i zwracany. Dzięki temu starsze komponenty, które nie zostały utworzone w ramach deklaratywnego DOM cienia, będą nadal działać, ponieważ deklaratywna część główna jest zachowana do momentu utworzenia imperatywnej zastępczej.

W przypadku nowo utworzonych elementów niestandardowych nowa właściwość ElementInternals.shadowRoot zapewnia wyraźny sposób uzyskiwania odwołania do istniejącego deklaratywnego katalogu głównego elementu, zarówno otwartego, jak i zamkniętego. Można go użyć do sprawdzenia i użycia dowolnego deklaratywnego korzenia cienia, ale nadal można użyć attachShadow() w przypadku, gdy nie podano takiego korzenia.

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);

Jeden cień na korzeń

Deklaratywna korzeń cienia jest powiązana tylko z elementem nadrzędnym. Oznacza to, że korzenie cienia są zawsze współlokowane z powiązanym elementem. Dzięki temu korzenie cienia są strumieniowane tak jak reszta dokumentu HTML. Jest to też wygodne podczas tworzenia i generowania, ponieważ dodanie do elementu katalogu cieni nie wymaga utrzymywania rejestru istniejących katalogów cieni.

Utworzenie związku między korzeniami cieni a ich elementami nadrzędnymi wiąże się z tym, że nie można zainicjować wielu elementów za pomocą tego samego deklaratywnego korzenia cienia <template>. W większości przypadków, gdy używany jest deklaratywny Shadow DOM, nie ma to jednak znaczenia, ponieważ zawartość każdego korzenia cienia rzadko jest identyczna. Chociaż kod HTML renderowany na serwerze często zawiera powtarzające się struktury elementów, ich zawartość zwykle się różni – na przykład ze względu na niewielkie różnice w tekście lub atrybutach. Treści serializowanego deklaratywnego korzenia cienia są całkowicie statyczne, więc uaktualnianie wielu elementów z jednego deklaratywnego korzenia cienia zadziałałoby tylko wtedy, gdyby elementy były identyczne. Wreszcie wpływ powtarzających się podobnych katalogów źródeł cieni na rozmiar przesyłania danych przez sieć jest stosunkowo niewielki z powodu efektów kompresji.

W przyszłości udostępnione korzenie cienia mogą zostać ponownie wykorzystane. Jeśli interfejs DOM będzie obsługiwał wbudowane szablony, deklaratywny korzeń cienia może być traktowany jako szablony, które są instancjonowane w celu zbudowania korzenia cienia dla danego elementu. Obecna deklaratywna konstrukcja Shadow DOM umożliwia to w przyszłości, ponieważ ogranicza powiązanie głównego elementu shadow do pojedynczego elementu.

Transmisja na żywo jest fajna

Powiązanie deklaratywnych korzeni cieni bezpośrednio z ich elementem nadrzędnym upraszcza proces ich ulepszania i dołączania do tego elementu. Deklaratywne korzenie cienia są wykrywane podczas analizowania kodu HTML i natychmiast dołączane, gdy napotkany jest ich otwierający tag <template>. Zanalizowany kod HTML w elementach <template> jest analizowany bezpośrednio w korzeniach cienia, dzięki czemu można go „przesyłać strumieniowo”: renderować w miarę otrzymywania.

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

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

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

Tylko parser

Deklaratywny shadow DOM to funkcja parsowania HTML. Oznacza to, że deklaratywny korzeń cienia będzie analizowany i dołączany tylko do tagów <template> z atrybutem shadowrootmode, które są obecne podczas analizowania kodu HTML. Inaczej mówiąc, deklaratywne korzenie cienia mogą być tworzone podczas początkowego parsowania HTML:

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

Ustawienie atrybutu shadowrootmode elementu <template> nie powoduje żadnego działania, a szablon pozostaje zwykłym elementem szablonu:

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

Aby uniknąć niektórych ważnych kwestii związanych z bezpieczeństwem, deklaratywnych korzeni cieni nie można też tworzyć za pomocą interfejsów API do analizowania fragmentów, takich jak innerHTML czy insertAdjacentHTML(). Jedynym sposobem analizowania kodu HTML z zaimplementowanymi deklaratywnymi korzeniami cienia jest użycie funkcji setHTMLUnsafe() lub 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>

Renderowanie po stronie serwera z uwzględnieniem stylów

Wewnętrzne i zewnętrzne arkusze stylów są w pełni obsługiwane w deklaratywnych korzeniach cieni za pomocą standardowych tagów <style><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>

Stylizacje określone w ten sposób są też bardzo zoptymalizowane: jeśli ta sama arkusz stylów jest obecny w kilku deklaratywnych korzeniach zduplikowanych, jest on wczytany i przeanalizowany tylko raz. Przeglądarka używa jednej pamięci podręcznej CSSStyleSheet, która jest współdzielona przez wszystkie korzenie cienia, co eliminuje duplikowanie pamięci.

Stylesheety z możliwością tworzenia nie są obsługiwane w deklaratywnym DOM w tle. Dzieje się tak, ponieważ obecnie nie ma możliwości serializacji stylów do tworzenia w HTML-u ani odwoływania się do nich podczas wypełniania adoptedStyleSheets.

Jak uniknąć wyświetlania niesformatowanych treści

Jednym z potencjalnych problemów w przeglądarkach, które nie obsługują jeszcze deklaratywnego modelu Shadow DOM, jest unikanie „błysku niesformatowanej zawartości” (FOUC), gdy w przypadku elementów niestandardowych, które nie zostały jeszcze ulepszone, wyświetlana jest surowa zawartość. Przed wprowadzeniem deklaratywnego shadow DOM jedną z popularnych technik unikania FOUC było zastosowanie reguły stylu display:none do elementów niestandardowych, które nie zostały jeszcze załadowane, ponieważ ich korzeń shadow nie został jeszcze dodany i wypełniony. Dzięki temu treści nie są wyświetlane, dopóki nie będą „gotowe”:

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

Dzięki wprowadzeniu deklaratywnego DOM-u cieniowego elementy niestandardowe można renderować lub tworzyć w HTML-u, tak aby ich zawartość cieniowa była gotowa i umieszczona na swoim miejscu przed załadowaniem implementacji komponentu po stronie klienta:

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

W tym przypadku reguła display:none „FOUC” uniemożliwi wyświetlanie treści deklaratywnego korzenia cienia. Jednak usunięcie tej reguły spowoduje, że przeglądarki bez obsługi deklaratywnego shadow DOM będą wyświetlać nieprawidłowe lub niestylizowane treści, dopóki polyfill deklaratywnego shadow DOM nie załaduje się i nie przekształci szablonu katalogu źródeł shadow DOM w prawdziwy katalog źródeł shadow DOM.

Na szczęście można to rozwiązać w CSS, modyfikując regułę stylu FOUC. W przeglądarkach, które obsługują deklaratywny shadow DOM, element <template shadowrootmode> jest natychmiast konwertowany na korzeń shadow, co powoduje, że w drzewie DOM nie ma elementu <template>. Przeglądarki, które nie obsługują deklaratywnego shadow DOM, zachowują element <template>, którego możemy użyć do zapobiegania FOUC:

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

Zamiast ukrywać jeszcze nie zdefiniowany element niestandardowy, zaktualizowana reguła „FOUC” ukrywa jego podrzędne, gdy występują one po elemencie <template shadowrootmode>. Po zdefiniowaniu elementu niestandardowego reguła nie będzie już pasować. Reguła jest ignorowana w przeglądarkach obsługujących deklaratywny DOM cieni, ponieważ element podrzędny <template shadowrootmode> jest usuwany podczas analizowania kodu HTML.

Wykrywanie funkcji i obsługa przeglądarek

Deklaratywna warstwa shadow DOM jest dostępna od wersji Chrome 90 i Edge 91, ale zamiast ustandaryzowanego atrybutu shadowrootmode używała starszego, niestandardowego atrybutu shadowroot. Nowy atrybut shadowrootmode i zachowanie strumieniowania są dostępne w Chrome 111 i Edge 111.

Jako nowy interfejs API platformy internetowej deklaratywny Shadow DOM nie jest jeszcze powszechnie obsługiwany we wszystkich przeglądarkach. Obsługę przeglądarki można wykryć, sprawdzając, czy w prototypie HTMLTemplateElement występuje właściwość shadowRootMode:

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

Watolina

Stworzenie uproszczonego kodu polyfill dla deklaratywnego modelu Shadow DOM jest stosunkowo proste, ponieważ kod polyfill nie musi idealnie odzwierciedlać semantyki czasowej ani cech związanych tylko z analizatorem, którymi zajmuje się implementacja przeglądarki. Aby wypełnić deklaratywny model Shadow DOM, możemy skanować DOM w celu znalezienia wszystkich elementów <template shadowrootmode>, a następnie przekształcić je w dołączone korzenie Shadow w ich elemencie nadrzędnym. Ten proces może zostać wykonany, gdy dokument jest gotowy, lub może być wywołany przez bardziej szczegółowe zdarzenia, takie jak cykle życia elementu niestandardowego.

(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);

Więcej informacji