Deklaracyjny DOM cienia

Jason Miller
Jason Miller
Mason Freed
Mason Freed

Deklaratywna warstwa shadow DOM to standardowa funkcja platformy internetowej, która jest obsługiwana w Chrome od wersji 90. Uwaga: specyfikacja tej funkcji zmieniła się w 2023 r. (w tym zmieniono nazwę z shadowroot na shadowrootmode), a najnowsze ustandaryzowane wersje wszystkich części tej funkcji trafiły do Chrome w wersji 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 ograniczanie stylów CSS do konkretnego poddrzewa DOM i wyodrębnianie tego poddrzewa od reszty dokumentu. Element <slot> umożliwia nam kontrolowanie, gdzie w drzewie cieniowym elementu niestandardowego mają być wstawiane elementy podrzędne. Dzięki połączeniu tych funkcji powstaje system tworzenia niezależnych komponentów wielokrotnego użytku, które płynnie integrują się z istniejącymi aplikacjami tak jak wbudowany element HTML.

Do tej pory jedynym sposobem korzystania z modelu Shadow DOM było utworzenie rdzenia cienia 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 JavaScript, które definiują nasze elementy niestandardowe, tworzą też ich korzenie cienia i ustalają ich zawartość. Jednak wiele aplikacji internetowych podczas kompilacji musi renderować treści po stronie serwera lub w trybie statycznego kodu HTML. Może to być ważny element zapewnienia satysfakcji użytkowników, którzy nie potrafią obsługiwać JavaScriptu.

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 tymczasowe wyświetlanie błysków bez stylu („FOUC”) podczas wczytywania arkuszy stylów Cienia.

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

Jak zbudować deklaratywny korzeń cienia

Deklaracyjny pierwiastek 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 jest zgodny z konwencjami panelu elementów w Narzędziach deweloperskich w Chrome dotyczącymi wyświetlania treści DOM. Na przykład znak reprezentuje treść Light DOM w boksach.

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

Hydraulowanie składników

Deklaratywnej struktury cienia DOM można używać samodzielnie jako sposobu opisywania stylów lub dostosowywania położenia podrzędnego, ale najlepiej sprawdza się w przypadku elementów niestandardowych. Komponenty utworzone przy użyciu elementów niestandardowych są automatycznie uaktualniane 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, gdy zostanie utworzony, bez konieczności jej jawnego tworzenia przez Twój kod. Najlepiej jest sprawdzić this.shadowRoot, czy w konstruktorze elementu nie ma już rdzenia cienia. Jeśli istnieje już wartość, kod HTML tego komponentu zawiera deklaratywny pierwiastek 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 stworzone z myślą o deklaratywnym modelu Shadow DOM, będą nadal działać, ponieważ pierwiastki deklaratywne są zachowywane do momentu utworzenia imperatywnego zastąpienia.

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 korzenia elementu schattenia, 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 pierwiastek

Deklaratywna korzeń cienia jest powiązana tylko z elementem nadrzędnym. Oznacza to, że pierwiastki cienia są zawsze współlokowane z powiązanym z nimi elementem. To rozwiązanie zapewnia, że korzenie cienia są strumieniowane tak jak reszta dokumentu HTML. Jest to również wygodne przy tworzeniu i generowaniu, ponieważ dodanie do elementu podstawowego poziomu cienia nie wymaga posiadania rejestru istniejących rdzeni cieni.

Zaletą powiązania pierwiastków cieni z ich elementem nadrzędnym jest to, że nie można zainicjować wielu elementów z tego samego deklaratywnego pierwiastka 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. Choć 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 elementu potomnego są całkowicie statyczne, więc uaktualnienie wielu elementów z jednego deklaratywnego elementu potomnego 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 model DOM obsługuje wbudowane szablony, deklaratywne rdzenie cienia mogą być traktowane jako szablony utworzone w celu utworzenia głównego źródła cienia dla danego elementu. Obecna konstrukcja deklaratywnego DOM Shadow stwarza taką możliwość w przyszłości, ograniczając powiązanie rdzenia cienia 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 od razu dołączane, gdy napotkany jest ich otwierający tag <template>. Przetworzony kod HTML w elemencie <template> jest analizowany bezpośrednio w katalogu głównym, więc można go „strumieniowo” renderować w momencie odbioru.

<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ąć pewnych ważnych kwestii związanych z bezpieczeństwem, deklaratywnych źródeł 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

Wbudowane i zewnętrzne arkusze stylów są w pełni obsługiwane w deklaratywnych źródłach cienia przy użyciu standardowych tagów <style> i <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>

Style określone w ten sposób są też wysoce zoptymalizowane: jeśli ten sam arkusz stylów występuje w kilku deklaratywnych korzeniach cienia, jest on ładowany i przeanalizowany tylko raz. Przeglądarka używa jednego zapasowego obiektu CSSStyleSheet, który jest współużytkowany przez wszystkie rdzenie cieni, eliminuje to zduplikowane wykorzystanie pamięci.

Konstruowalne arkusze stylów nie są obsługiwane w deklaratywnym DOM. Dzieje się tak, ponieważ w tej chwili nie ma możliwości serializowania konstruowalnych arkuszy stylów w kodzie HTML ani możliwości odwoływania się do nich podczas wypełniania pola 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 „flash of unstyled content” (FOUC), w którym w przypadku elementów niestandardowych, które nie zostały jeszcze uaktualnione, wyświetlana jest nieprzetworzona zawartość. Przed wprowadzeniem deklaratywnego DOM Shadow jedną z metod unikania FOUC było stosowanie reguły stylu display:none do elementów niestandardowych, które nie zostały jeszcze wczytane, ponieważ nie miały przypisanego i uzupełnionego elementu głównego cienia. Dzięki temu zawartość nie będzie wyświetlana, dopóki nie będzie „gotowa”:

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

Dzięki wprowadzeniu deklaratywnego modelu Shadow DOM elementy niestandardowe można renderować i tworzyć w kodzie HTML, tak aby ich treść w cieniu znajdowała się na miejscu i była gotowa 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 „FOUC” display:none zapobiegłaby wyświetlaniu treści pochodzących z deklaratywnego rdzenia cienia. Usunięcie tej reguły spowodowałoby jednak, że przeglądarki bez obsługi deklaratywnej struktury cienia DOM będą wyświetlać nieprawidłowe lub niesformatowane treści, dopóki polyfill deklaratywnego DOM Shadow DOM nie załaduje i nie przekonwertuje szablonu cienia głównego na rzeczywisty katalog cienia.

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 modelu cienia DOM, zachowują element <template>, którego można użyć, aby zapobiec FOUC:

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

Zamiast jeszcze ukrywać jeszcze niezdefiniowany element niestandardowy, poprawiona reguła „FOUC” ukrywa elementy podrzędne, gdy podążają za elementem <template shadowrootmode>. Po zdefiniowaniu elementu niestandardowego reguła przestaje być dopasowywana. Reguła jest ignorowana w przeglądarkach, które obsługują deklaratywny DOM Shadow, ponieważ element podrzędny <template shadowrootmode> jest usuwany podczas analizy 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. Nowszy atrybut shadowrootmode i działanie strumieniowania są dostępne w Chrome 111 i Edge 111.

Jest to nowy interfejs API platformy internetowej, więc deklaratywny DOM Shadow nie jest jeszcze obsługiwany we wszystkich przeglądarkach. Obsługę przeglądarki można wykryć, sprawdzając, czy w prototypie HTMLTemplateElement istnieje 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 wykorzystać kod Deklaratywny Shadow DOM, możemy przeskanować obiekt DOM w poszukiwaniu wszystkich elementów <template shadowrootmode>, a następnie przekonwertować je na dołączone korzenie cienia w 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